fix(text): preserve document state while threading and formatting

Merge current main into the text editing and layout work, retaining the original contributor commits. Preserve generated and linked stories, incoming text references, local IDs and locked frames when threading.

Keep pending formatting and text colours consistent, isolate deferred control clipboard input, and handle inherited IDML frame properties with bounded column allocations. Add focused state-preservation and round-trip regressions and correct the socket and swatch test fixtures.

M2; refs #84.
This commit is contained in:
skylar taylor-barrick committed 2026-10-13 19:54:50 -06:00
commit 9b7a0ec66d
125 files changed
+10721 -661

No files matched your search

+6
View File
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
Generated
+22 -22
View File
@@ -887,7 +887,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"arboard",
"designcraft-engine",
@@ -905,7 +905,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -921,7 +921,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"kurbo",
"moxcms",
@@ -933,7 +933,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -951,7 +951,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -963,7 +963,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"caseless",
"craft-print",
@@ -998,7 +998,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1007,7 +1007,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -1019,7 +1019,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-doc",
"serde",
@@ -1030,7 +1030,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"flo_curves",
"kurbo",
@@ -1043,7 +1043,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1056,7 +1056,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-fonts",
@@ -1067,7 +1067,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1080,7 +1080,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1102,7 +1102,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1120,7 +1120,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
@@ -1129,7 +1129,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1141,7 +1141,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1159,6 +1159,7 @@ dependencies = [
"regex",
"serde",
"serde_json",
"tempfile",
"toml",
"unicode-bidi",
"unicode-bidi-mirroring",
@@ -1168,7 +1169,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5519,8 +5520,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
dependencies = [
"ahash",
"android-activity",
@@ -5688,7 +5688,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"serde_json",
]
+5 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -97,3 +97,7 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
+1
View File
@@ -106,6 +106,7 @@ cargo run --release -p designcraft -- --sample # open the sample mag
cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel
cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG
cargo run --release -p designcraft-cli -- commands # list every command
cargo run --release -p designcraft-cli -- script --help # command script syntax (docs/agents.md)
cargo xtask ci # fmt, clippy, tests, assets, layering, wasm
```
+26
View File
@@ -135,7 +135,33 @@ Newest first.
`text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change.
- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change.
- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change.
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change.
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change.
- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change.
- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change.
- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change.
- **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change.
- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change.
- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change.
- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change.
- **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change.
- **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change.
- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change.
- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
+36 -1
View File
@@ -38,6 +38,37 @@ 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";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with
--connect PORT, in the running app (designcraft --control PORT).
Script text, one step per line:
command.id {"param": 1} also `command.id={json}`, or `command.id` alone
{"command": "command.id", "params": {...}} JSON lines, or a JSON array of them
# comment, // comment skipped, like blank lines
UTF-8 with or without a byte order mark; LF or CRLF line endings.
References to earlier results (in string parameters):
"$N.path" the value at `path` in the result of step N. N is the 0-based step index:
comments and blank lines are not steps. `$last` is the previous step.
Paths go through result fields and array indices: "$1.story", "$3.ids.0".
"${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}".
Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab.
Most commands ignore parameters they do not know instead of rejecting them: check names
with `designcraft-cli describe COMMAND`.
Options:
--in FILE / --sample open a document (.designcraft or .idml) or the sample first
--save OUT save as .designcraft after the script succeeds
--export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable
--keep-going run every step, recording failures as {"error": ...} results
Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a
non-zero exit when a step fails."#;
/// The links line under the usage block.
fn usage_footer() -> String {
format!(
@@ -281,7 +312,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str
.render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() })
.ok_or("no such page")?;
let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
Ok(())
}
@@ -335,6 +366,10 @@ fn script(args: &[String]) -> Result<(), String> {
"--save" => save = Some(val()?),
"--export" => export.push(val()?),
"--keep-going" => keep_going = true,
"--help" | "-h" => {
outln!("{SCRIPT_HELP}");
return Ok(());
}
f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()),
other => return Err(format!("unknown script option `{other}`")),
}
+47
View File
@@ -87,6 +87,21 @@ fn help_goes_to_stdout_and_unknown_args_to_stderr() {
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli"));
}
/// `script --help` was "unknown script option `--help`"; the line syntax was only in
/// `describe script.run` (#654).
#[test]
fn script_help_documents_the_syntax() {
for flag in ["--help", "-h"] {
let out = cli().args(["script", flag]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
assert!(stdout.starts_with("usage: designcraft-cli script"), "{flag}: {stdout:?}");
for needle in ["0-based", "$1.story", "$3.ids.0", "\\u2028", "ignore"] {
assert!(stdout.contains(needle), "{flag}: no {needle:?} in {stdout:?}");
}
}
}
/// `designcraft-cli commands | head -1` panicked with "failed printing to
/// stdout: Broken pipe (os error 32)" and exit status 101.
#[test]
@@ -122,3 +137,35 @@ fn run_export_options_come_before_their_export() {
assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn raster_export_preserves_an_existing_reader_and_private_symlink_target() {
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fixture = Fixture(dir);
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
let old = b"previous private export";
std::fs::write(&target, old).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
let alias = fixture.0.join("linked.png");
symlink(target.file_name().unwrap(), &alias).unwrap();
let mut reader = std::fs::File::open(&target).unwrap();
let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let mut observed = Vec::new();
reader.read_to_end(&mut observed).unwrap();
assert!(observed == old, "CLI export changed bytes under an existing reader");
assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
+2
View File
@@ -32,4 +32,6 @@ arboard = { workspace = true }
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "macos")'.dependencies]
# The same winit as eframe; only the native termination opt-in is used.
winit = { version = "0.30", default-features = false }
muda = { version = "0.21", default-features = false }
+198 -1
View File
@@ -64,10 +64,33 @@ impl eframe::App for App {
if let Some(s) = self.startup.as_mut() {
s.presented();
}
if let Err(e) = preserve_documents_on_exit(&mut self.app) {
log::error!("Couldn't preserve documents on exit: {e}");
eprintln!("designcraft: couldn't preserve documents on exit: {e}");
}
save_prefs(&self.app);
}
}
/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete,
/// current quit plan; otherwise preserve every live document, including edits after the timer.
fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> {
app.finish_confirmed_quit()?;
if app.session.documents().is_empty() {
return Ok(());
}
if app.session.recovery_dir.is_none() {
return if app.session.documents().iter().any(|d| d.is_dirty()) {
Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into())
} else {
Ok(())
};
}
// Also discard stale recovery for clean documents (for example, Undo back to the saved
// state after the last timer tick). Never choose a fallback recovery path here.
app.run("file.recovery.save", serde_json::json!({})).map(|_| ())
}
/// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos).
fn no_prefs() -> bool {
std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some()
@@ -186,7 +209,7 @@ fn services() -> Services {
})),
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),
..Default::default()
}
@@ -277,6 +300,15 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through
// ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow.
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|builder| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
builder.with_close_requested_on_terminate(true);
}));
}
eframe::run_native(
"DesignCraft",
options,
@@ -354,3 +386,168 @@ mod tests {
assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft")));
}
}
#[cfg(all(test, unix))]
mod atomic_writer_tests {
#[test]
fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() {
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fixture = Fixture(dir);
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
let old = b"previous private export";
std::fs::write(&target, old).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
let alias = fixture.0.join("linked.png");
symlink(target.file_name().unwrap(), &alias).unwrap();
let mut reader = std::fs::File::open(&target).unwrap();
let mut write = super::services().write.unwrap();
write(alias.to_str().unwrap(), b"complete new export").unwrap();
let mut observed = Vec::new();
reader.read_to_end(&mut observed).unwrap();
assert!(observed == old, "desktop export changed bytes under an existing reader");
assert_eq!(std::fs::read(&target).unwrap(), b"complete new export");
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
}
#[cfg(test)]
mod exit_recovery_tests {
use super::*;
use serde_json::json;
fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) {
let mut app = DesignApp::new(Session::new(), Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
let uid = app.session.active().unwrap().uid;
let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id()));
assert!(!dir.exists());
app.session.recovery_dir = Some(dir.clone());
(app, dir)
}
fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) {
let mut raw = egui::RawInput::default();
if close {
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
}
let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() {
let (mut app, dir) = dirty_app("latest");
app.run("file.recovery.save", json!({})).unwrap();
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
let d = app.session.active().unwrap();
let (uid, revision, saved_revision, doc, selection, undo, redo) =
(d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len());
preserve_documents_on_exit(&mut app).unwrap();
let current = app.session.active().unwrap();
assert_eq!(
(current.uid, current.revision, current.saved_revision, &current.selection.items, current.history.undo.len(), current.history.redo.len()),
(uid, revision, saved_revision, &selection, undo, redo)
);
assert!(std::sync::Arc::ptr_eq(&current.doc, &doc));
assert!(current.is_dirty());
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_removes_stale_data_after_undo_to_saved_state() {
let (mut app, dir) = dirty_app("clean");
let uid = app.session.active().unwrap().uid;
app.run("file.recovery.save", json!({})).unwrap();
let entry = dir.join(format!("{uid}.designcraft"));
assert!(entry.exists());
app.run("edit.undo", json!({})).unwrap();
assert!(!app.session.active().unwrap().is_dirty());
let doc = app.session.active().unwrap().doc.clone();
preserve_documents_on_exit(&mut app).unwrap();
assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits");
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() {
let (mut app, dir) = dirty_app("failure");
let doc = app.session.active().unwrap().doc.clone();
app.session.recovery_dir = None;
assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable"));
std::fs::create_dir(&dir).unwrap();
let blocked = dir.join("not-a-directory");
std::fs::write(&blocked, b"preserve this file").unwrap();
app.session.recovery_dir = Some(blocked.clone());
assert!(preserve_documents_on_exit(&mut app).is_err());
assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file");
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
assert!(app.session.active().unwrap().is_dirty());
assert_eq!(app.session.documents().len(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() {
for case in ["complete", "incomplete", "stale", "new"] {
let (mut app, dir) = dirty_app(case);
if case == "incomplete" {
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
}
app.run("file.recovery.save", json!({})).unwrap();
let first_uid = app.session.documents()[0].uid;
let ctx = egui::Context::default();
close_frame(&mut app, &ctx, true);
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
designcraft_ui_egui::dialogs::confirm(&mut app).unwrap();
close_frame(&mut app, &ctx, false);
if case == "stale" {
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
} else if case == "new" {
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
}
let active = app.session.active().unwrap().uid;
let before: Vec<_> = app
.session
.documents()
.iter()
.map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()))
.collect();
preserve_documents_on_exit(&mut app).unwrap();
if case == "complete" {
assert!(app.session.documents().is_empty());
assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered");
} else {
assert_eq!(app.session.documents().len(), before.len(), "{case}");
assert_eq!(app.session.active().unwrap().uid, active, "{case}");
for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) {
assert_eq!(
(d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()),
(uid, revision, &selection, undo, redo),
"{case}"
);
assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}");
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}");
}
}
std::fs::remove_dir_all(dir).unwrap();
}
}
}
+4 -1
View File
@@ -40,6 +40,8 @@ impl NativeMenu {
// Our own About splash (with the community links) instead of the stock panel.
let about = MenuItem::with_id("dc-about", "About DesignCraft", true, None);
let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", true, None);
// Not the predefined Quit: it terminates the app at once, past the unsaved-changes prompt.
let quit = MenuItem::with_id("dc-quit", designcraft_ui_egui::i18n::tr(&app.ui.language, "Quit DesignCraft"), true, accel("Cmd+Q"));
let _ = app_menu.append_items(&[
&about,
&discord,
@@ -50,10 +52,11 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
&quit,
]);
items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about)));
items.insert("dc-discord".into(), ("help.discord".into(), serde_json::Value::Null, Handle::Plain(discord)));
items.insert("dc-quit".into(), ("app.quit".into(), serde_json::Value::Null, Handle::Plain(quit)));
let _ = menu.append(&app_menu);
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true);
+38 -1
View File
@@ -22,7 +22,19 @@ fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<P
}
pub fn recovery_dir() -> Option<PathBuf> {
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir)
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).and_then(own_run)
}
/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so
/// neither writes over the other's documents nor takes them for a crash's.
fn own_run(root: PathBuf) -> Option<PathBuf> {
match designcraft_engine::recovery::run_dir(&root) {
Ok(dir) => Some(dir),
Err(e) => {
log::warn!("recovery is unavailable because this run cannot be isolated: {e}");
None
}
}
}
fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
@@ -68,4 +80,29 @@ mod tests {
assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into()));
assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into()));
}
#[test]
fn a_run_has_its_own_recovery_folder_when_one_can_be_made() {
let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let root = dir.join("Recovery");
assert_eq!(own_run(root.clone()).as_deref().and_then(|dir| dir.parent()), Some(root.as_path()));
// Failure must not fall back to the shared root.
let blocked = dir.join("blocked");
std::fs::write(&blocked, "a file where the folder should be").unwrap();
assert_eq!(own_run(blocked.join("Recovery")), None, "isolation failure must not select the shared root");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_exhausted_run_namespace_never_falls_back_to_shared_recovery() {
let root = std::env::temp_dir().join(format!("designcraft-profile-exhausted-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
for n in 0..64 {
std::fs::create_dir(root.join(format!("run-{}-{n}", std::process::id()))).unwrap();
}
assert_eq!(own_run(root.clone()), None, "a shared root would restore inter-process UID collisions");
let _ = std::fs::remove_dir_all(&root);
}
}
+20 -1
View File
@@ -104,9 +104,12 @@ impl Gradient {
out
}
pub fn reverse(&mut self) {
// A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors.
let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect();
self.stops.reverse();
for s in &mut self.stops {
for (s, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) {
s.offset = 1.0 - s.offset;
s.midpoint = m;
}
}
pub fn sort(&mut self) {
@@ -219,8 +222,24 @@ mod tests {
#[test]
fn reverse_stops() {
let mut g = Gradient::default();
g.stops[0].midpoint = 0.25;
g.reverse();
assert_eq!(g.stops[0].color.to_hex(), "#000000");
assert_eq!(g.stops[0].offset, 0.0);
// The midpoint moves to its reversed segment and mirrors.
assert!((g.sample(0.75).0.to_rgb()[0] - 0.5).abs() < 1e-5);
assert_eq!(g.stops.iter().map(|s| s.midpoint).collect::<Vec<_>>(), [0.75, 0.5]);
// With several stops, the reversed gradient is the original mirrored.
let mut g = Gradient::default();
g.stops[0].midpoint = 0.3;
g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 });
let original = g.clone();
g.reverse();
for t in [0.1, 0.3, 0.5, 0.7, 0.9] {
let ((c, o), (want_c, want_o)) = (g.sample(t), original.sample(1.0 - t));
assert!(c.to_rgb().iter().zip(want_c.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "colour at {t}");
assert!((o - want_o).abs() < 1e-5, "opacity at {t}");
}
}
}
+21 -11
View File
@@ -13,7 +13,8 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode};
use crate::vars::RunningIndex;
use crate::{ComposeOptions, ComposedStory};
type Key = (StoryId, Option<String>);
/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`).
type Key = (StoryId, Option<usize>);
struct Entry {
sig: Vec<usize>,
@@ -55,11 +56,23 @@ impl Cache {
Self::default()
}
/// The composed story, from cache or freshly composed.
pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
/// The composed story, from cache or freshly composed. `page` is the absolute page a
/// parent-page item is shown on (its page number markers and variables are that page's), or
/// `None` for a story's own frames. Pages are identified by index, not name: names repeat when
/// sections restart their numbering.
pub fn get(&self, doc: &Document, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> {
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
let (mut sig, keep_items) = signature(doc, story);
let key = (sid, page_name.map(str::to_string));
let key = (sid, page);
// The key holds the page's index, which a renumbering doesn't change: its name (section
// start number, style, prefix) is part of the signature.
let page_name = page.map(|p| doc.page_name(p));
if let Some(name) = &page_name {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
name.hash(&mut h);
sig.push(h.finish() as usize);
}
let with_vars = crate::vars::has_vars(story);
let running = with_vars && crate::vars::has_running(doc, story);
if with_vars {
@@ -125,23 +138,20 @@ impl Cache {
return e.out.clone();
}
}
// Parent-page items are composed per page; variables need that page's index.
let page = match page_name {
Some(n) if with_vars => (0..doc.page_count()).find(|&i| doc.page_name(i) == n),
_ => None,
};
// Parent-page items are composed per page: markers show its name, variables need its index.
let page = page.filter(|_| with_vars);
let running = running.then(|| self.running_index(doc));
let out = Arc::new(crate::compose_story(
doc,
sid,
&ComposeOptions { page_name: page_name.map(str::to_string), page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
&ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
));
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let stamp = g.1;
if g.0.len() > 4096 {
g.0.retain(|_, e| stamp - e.stamp < 64);
}
let replaced = g.0.insert(key.clone(), Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
drop(g);
if key.1.is_none()
&& let Some(old) = replaced
+108 -3
View File
@@ -330,7 +330,8 @@ pub struct FrameSpec {
impl FrameSpec {
pub fn columns(&self) -> Vec<Rect> {
let a = self.area;
let n = self.opts.columns.max(1);
// Primary frames inherit the document limit (216), beyond the interactive frame editor's 40.
let n = self.opts.columns.clamp(1, 216);
match self.opts.columns_kind {
designcraft_doc::ColumnsKind::FixedWidth if self.opts.column_width > 0.0 => (0..n)
.map(|i| {
@@ -431,7 +432,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
path: None,
..tf.options.clone()
};
(Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts)
let end = pt.end.map_or(len, |e| e.max(0.0).min(len));
(Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts)
}
None => (item.text_area(), tf.options.clone()),
};
@@ -1563,7 +1565,9 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight,
hyphen_limit: pp.hyph_limit,
ragged_stretch: base_size * 2.0,
hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone },
// The zone is the Single-line Composer's (unjustified text only), as in InDesign: the
// Paragraph Composer weighs every break of the paragraph instead.
hyph_zone: if pp.align.is_justified() || pp.composer != Composer::SingleLine { 0.0 } else { pp.hyph_zone },
optical: pp.optical_margin,
korean_char_breaks: pp.korean_char_breaks,
}
@@ -2438,6 +2442,18 @@ fn layout_line(
// A default tab stop is a left tab.
last_tab = Some((i, stop.as_ref().is_none_or(|t| t.align == TabAlign::Left)));
} else if line[i].ch == story::RIGHT_INDENT_TAB {
// Use the first stop past the right margin, or the last stop at/before it.
// Choose the stop before checking its leader: an empty leader takes precedence too.
let right = x1 - tab_origin;
let stop = pp
.tabs
.iter()
.filter(|t| t.position > right)
.min_by(|a, b| a.position.total_cmp(&b.position))
.or_else(|| pp.tabs.iter().filter(|t| t.position <= right).max_by(|a, b| a.position.total_cmp(&b.position)));
if let Some(t) = stop.filter(|t| !t.leader.is_empty()) {
leaders.push((i, t.leader.clone()));
}
let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum();
line[i].adv = (measure - x - rest).max(0.0);
last_tab = Some((i, false));
@@ -3563,6 +3579,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
#[cfg(test)]
mod tests;
/// Type on a path: between the composed line (x along it, y as composed) and the frame's path
/// space, as `path_glyphs` places the glyphs.
pub struct PathTextMap {
warp: designcraft_geom::warp::PathWarp,
start: f64,
/// Line y that sits on the path (the alignment), and the line's baseline.
on: f64,
baseline: f64,
}
impl PathTextMap {
/// The map of `item` (type on a path) for its composed frame `ft`; None for other frames.
pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option<Self> {
let pt = item.text_frame()?.options.path.as_ref()?;
let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip);
let (on, baseline) = match ft.lines.first() {
Some(l) => (
match pt.align {
designcraft_doc::PathAlign::Baseline => l.baseline,
designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent,
designcraft_doc::PathAlign::Descender => l.baseline + l.descent,
designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0,
},
l.baseline,
),
None => (0.0, 0.0),
};
Some(PathTextMap { warp, start: pt.start, on, baseline })
}
/// A point of the composed line, on the path: along it by x, across it by y.
pub fn to_path(&self, p: Point) -> Point {
match self.warp.at(self.start + p.x) {
Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on),
None => p,
}
}
/// A point of the path space, on the composed line: how far along the path its nearest point
/// is, at the baseline (for hit testing).
pub fn to_line(&self, p: Point) -> Point {
Point::new(self.warp.nearest(p) - self.start, self.baseline)
}
}
/// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space
/// (grouped by run style).
pub fn path_glyphs(
@@ -3613,3 +3674,47 @@ pub fn path_glyphs(
}
runs
}
#[cfg(test)]
mod column_allocation_safety_tests {
use super::*;
fn frame(kind: designcraft_doc::ColumnsKind) -> FrameSpec {
FrameSpec {
id: ItemId(1),
area: Rect::new(0., 0., 300., 100.),
opts: TextFrameOptions { columns: 217, columns_kind: kind, column_width: 20., gutter: 12., ..Default::default() },
vertical: false,
exclusions: vec![],
page_name: None,
page: None,
grid: None,
left_page: false,
page_rect: None,
}
}
fn check(kind: designcraft_doc::ColumnsKind) {
let mut f = frame(kind);
// Fail safely at a small boundary before exercising hostile native options.
assert_eq!(f.columns().len(), 216);
f.opts.columns = u32::MAX;
assert_eq!(f.columns().len(), 216);
f.opts.columns = 0;
assert_eq!(f.columns().len(), 1);
f.opts.columns = 2;
let expected = if kind == designcraft_doc::ColumnsKind::FixedWidth {
vec![Rect::new(0., 0., 20., 100.), Rect::new(32., 0., 52., 100.)]
} else {
vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)]
};
assert_eq!(f.columns(), expected);
}
#[test]
fn column_allocation_caps_equal_width_frame_count() {
check(designcraft_doc::ColumnsKind::FixedNumber);
}
#[test]
fn column_allocation_caps_fixed_width_frame_count() {
check(designcraft_doc::ColumnsKind::FixedWidth);
}
}
+146 -5
View File
@@ -523,11 +523,16 @@ fn hyphenation_zone_limits_ragged_hyphens() {
let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs).iter().filter(|l| l.hyphenated).count()
};
for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c));
// A huge zone leaves only words that start a line (no space to break at) hyphenable.
assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}");
}
// Single-line Composer: a huge zone leaves only words that start a line (no space to break
// at) hyphenable.
let c = designcraft_doc::Composer::SingleLine;
let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c));
assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}");
// The zone is the Single-line Composer's only (InDesign): the Paragraph Composer hyphenates
// the same whatever it is.
let c = designcraft_doc::Composer::Paragraph;
let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c));
assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}");
}
#[test]
@@ -1028,6 +1033,125 @@ fn tab_leaders_fill_the_gap() {
assert!(last_dot < num_x, "leaders stop before the page number");
}
#[test]
fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() {
let text = "Intro\u{e006}12";
let cases = [
("last interior", 0.0, vec![(200.0, "."), (80.0, "-")], Some('.')),
("first exterior", 0.0, vec![(380.0, "+"), (200.0, "-"), (320.0, ".")], Some('.')),
("at the margin", 0.0, vec![(300.0, "."), (80.0, "-")], Some('.')),
("no stops", 0.0, vec![], None),
("empty exterior", 0.0, vec![(200.0, "."), (320.0, ""), (350.0, "+")], None),
("empty interior", 0.0, vec![(80.0, "."), (200.0, "")], None),
("indented margin", 36.0, vec![(260.0, "-"), (280.0, "."), (340.0, "+")], Some('.')),
];
for (name, right_indent, stops, expected) in cases {
let tabs = stops
.into_iter()
.map(|(position, leader)| designcraft_doc::TabStop { position, align: TabAlign::Right, leader: leader.into(), align_on: String::new() })
.collect();
let para = ParaAttrs { tabs: Some(tabs), left_indent: Some(12.0), right_indent: Some(right_indent), ..Default::default() };
// Translation and left/right indents distinguish the tab origin from line/absolute x.
let (mut d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 372.0, 136.0), para);
let cs = compose_story(&d, sid, &Default::default());
assert!(!cs.is_overset(), "{name}");
assert_eq!(cs.frames[0].lines.len(), 1, "{name}");
let line = &cs.frames[0].lines[0];
let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap();
let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect();
assert_eq!(!leaders.is_empty(), expected.is_some(), "{name}");
for g in leaders {
assert_eq!(g.gid, g.face.get().glyph_for(expected.unwrap()), "{name}");
let width = g.face.get().advance(g.gid) * g.sx;
assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01, "{name}: leader outside tab gap");
assert_eq!(g.byte, tab.byte, "{name}");
}
assert!((line.end_x - (372.0 - right_indent)).abs() < 0.01, "{name}: number stays at the right margin");
// Removing only the leaders must leave every source glyph and the line itself unchanged.
for t in d.story_mut(sid).unwrap().paras[0].para.tabs.as_mut().unwrap() {
t.leader.clear();
}
let plain = compose_story(&d, sid, &Default::default());
assert_eq!(plain.frames[0].lines.len(), 1, "{name}");
let plain_line = &plain.frames[0].lines[0];
assert_eq!((&line.range, line.baseline, line.end_x), (&plain_line.range, plain_line.baseline, plain_line.end_x), "{name}");
let source_positions = |l: &Line| {
l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len, g.gid, g.x, g.y, g.adv, g.sx, g.sy, g.style)).collect::<Vec<_>>()
};
assert_eq!(source_positions(line), source_positions(plain_line), "{name}");
assert_eq!(d.stories[&sid].text, text, "{name}: source text is not expanded");
}
}
#[test]
fn right_indent_tab_leaders_use_the_tab_format_and_regular_tab_drawing() {
let mut rendered = Vec::new();
for ch in [story::RIGHT_INDENT_TAB, '\t'] {
let text = format!("Intro{ch}12");
let para = ParaAttrs {
tabs: Some(vec![designcraft_doc::TabStop { position: 300.0, align: TabAlign::Right, leader: ".-".into(), align_on: String::new() }]),
..Default::default()
};
let (mut d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 372.0, 136.0), para);
d.story_mut(sid).unwrap().format_chars(5..5 + ch.len_utf8(), |f| {
f.over.font_family = Some("Inter".into());
f.over.size = Some(18.0);
f.over.h_scale = Some(1.3);
f.over.v_scale = Some(0.8);
f.over.baseline_shift = Some(4.0);
f.over.fill_tint = Some(0.4);
});
let cs = compose_story(&d, sid, &Default::default());
assert!(!cs.is_overset());
assert_eq!(cs.frames[0].lines.len(), 1);
let line = &cs.frames[0].lines[0];
let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap();
let prefix = &line.glyphs[0];
assert_ne!(tab.face.id(), prefix.face.id(), "tab uses its own font");
assert_ne!(tab.style, prefix.style, "tab uses its own character formatting");
assert_eq!(cs.styles[tab.style as usize].size, 18.0);
assert_eq!(cs.styles[tab.style as usize].fill_tint, 0.4);
let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect();
assert!(leaders.len() > 10);
assert_eq!(leaders.len() % 2, 0, "only complete patterns are drawn");
for (i, g) in leaders.iter().enumerate() {
assert_eq!(g.gid, tab.face.get().glyph_for(if i % 2 == 0 { '.' } else { '-' }));
assert_eq!(g.face.id(), tab.face.id());
assert_eq!((g.sx, g.sy, g.y, g.style, g.byte), (tab.sx, tab.sy, -4.0, tab.style, tab.byte));
let width = g.face.get().advance(g.gid) * g.sx;
assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01);
}
rendered.push(leaders.iter().map(|g| (g.gid, g.x, g.y, g.sx, g.sy, g.style)).collect::<Vec<_>>());
}
assert_eq!(rendered[0], rendered[1], "right-indent and equivalent regular right tabs use the same leader drawing");
}
#[test]
fn right_indent_tab_leaders_do_not_draw_when_the_gap_cannot_fit_a_pattern() {
let text = "A\u{e006}B";
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &Default::default());
let line = &cs.frames[0].lines[0];
let natural: f64 = line.glyphs.iter().filter(|g| g.visible).map(|g| g.adv).sum();
let first = &line.glyphs[0];
let pattern_width = first.face.get().advance(first.face.get().glyph_for('-')) * first.sx;
assert!(pattern_width > 0.01);
for gap in [0.0, pattern_width / 2.0] {
let para = ParaAttrs {
tabs: Some(vec![designcraft_doc::TabStop { position: 1.0, align: TabAlign::Left, leader: "-".into(), align_on: String::new() }]),
..Default::default()
};
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, natural + gap, 100.0), para);
let cs = compose_story(&d, sid, &Default::default());
assert!(!cs.is_overset());
assert_eq!(cs.frames[0].lines.len(), 1);
let line = &cs.frames[0].lines[0];
let tab = line.glyphs.iter().find(|g| g.byte == 1 && g.len > 0).unwrap();
assert!((tab.adv - gap).abs() < 1e-6);
assert!(line.glyphs.iter().all(|g| g.len > 0 || !g.visible), "a partial leader pattern must not overlap adjacent text");
}
}
#[test]
fn footnotes_sit_at_the_column_bottom_and_push_text() {
// A short frame: without footnotes the text fills it; with two footnotes the body text
@@ -2917,6 +3041,23 @@ fn cjk_fallback_follows_the_language() {
assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone()));
}
#[test]
fn malayalam_falls_back_to_a_font_that_forms_its_conjuncts() {
// Run against the installed fonts: on macOS the first file mapping Malayalam is Arial
// Unicode MS, which leaves ക്ഷ as three glyphs.
let db = designcraft_fonts::FontDb::global();
if !["Malayalam Sangam MN", "Malayalam MN", "Nirmala UI", "Noto Sans Malayalam"].iter().any(|f| db.has_family(f)) {
return;
}
let text = "ക്ഷ";
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
d.settings.glyph_fallback = true;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let glyphs: Vec<_> = all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face.family.clone(), g.gid)).collect();
assert_eq!(glyphs.len(), 1, "one conjunct: {glyphs:?}");
assert_ne!(glyphs[0].1, 0);
}
#[test]
fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() {
use designcraft_fonts::testing::font_with;
+87 -1
View File
@@ -209,6 +209,7 @@ impl Document {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
self.check_thread_links(from, to, false)?;
let ts = self.frame_story(to)?;
let fs = match self.frame_story(from)? {
Some(s) => s,
@@ -232,8 +233,10 @@ impl Document {
let after = other.frames.split_off(pos_to);
let before = std::mem::take(&mut other.frames);
let (vertical, direction) = (other.vertical, other.direction);
let offset;
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) };
// The story keeps its own direction, even when it was empty and takes the other's text.
let own = (st.vertical, st.direction);
st.append_story(other);
@@ -244,6 +247,7 @@ impl Document {
}
st.rev += 1;
}
self.remap_thread_story_refs(ts, fs, offset);
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
@@ -274,8 +278,10 @@ impl Document {
let ts = self.frame_story(to)?;
let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to));
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) };
self.check_thread_links(from, to, true)?;
if let Some(fs) = fs {
self.stories.remove(&fs);
self.remap_thread_story_refs(fs, ts, 0);
}
let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?;
st.frames.insert(0, from);
@@ -288,6 +294,63 @@ impl Document {
Ok(())
}
/// Story-external text addresses move with the incoming text when threading merges stories.
/// Linked children follow the surviving whole story, including for an empty removed parent;
/// their copied content stays unchanged until an explicit Update Link.
fn remap_thread_story_refs(&mut self, from: StoryId, to: StoryId, offset: usize) {
// The child's old snapshot is not a snapshot of the newly merged parent. Force it
// out of date even when the old and surviving revision numbers happen to coincide.
let stale_rev = self.story(to).map_or(0, |st| st.rev.wrapping_sub(1));
for st in self.stories.values_mut() {
if st.link.is_some_and(|(parent, _)| parent == from) {
Arc::make_mut(st).link = Some((to, stale_rev));
}
}
for link in &mut self.hyperlinks {
if let crate::HyperlinkSource::Text { story, start, end } = &mut link.source
&& *story == from
{
*story = to;
*start = start.saturating_add(offset);
*end = end.saturating_add(offset);
}
}
for placeholder in &mut self.data_merge.placeholders {
if let crate::PlaceholderAnchor::Text { story, start, end } = &mut placeholder.anchor
&& *story == from
{
*story = to;
*start = start.saturating_add(offset);
*end = end.saturating_add(offset);
}
}
if self.settings.primary_story == Some(from) {
self.settings.primary_story = Some(to);
}
}
/// A linked child is replaced wholesale on update. Only empty, unlinked frame
/// extensions can join it without losing a link or newly appended body text.
fn check_thread_links(&self, from: ItemId, to: ItemId, keep_to: bool) -> Result<()> {
let fs = self.frame_story(from)?;
let ts = self.frame_story(to)?;
let first = fs.and_then(|id| self.story(id));
let second = ts.and_then(|id| self.story(id));
let incompatible = if keep_to {
first.is_some_and(|st| st.link.is_some()) || second.is_some_and(|st| st.link.is_some_and(|(parent, _)| Some(parent) == fs))
} else {
second.is_some_and(|st| st.link.is_some())
|| first.is_some_and(|st| {
st.link.is_some()
&& second.is_some_and(|incoming| !incoming.text.is_empty() || st.link.is_some_and(|(parent, _)| parent == incoming.id))
})
};
if incompatible {
return Err(DocError::Invalid("unlink the linked story before merging its text".into()));
}
Ok(())
}
/// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on
/// document spreads or on the same parent spread, not in one story already, and no frame
/// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow).
@@ -316,6 +379,23 @@ impl Document {
if story(from).is_some() && story(from) == story(to) {
return Err(DocError::Invalid("the frames are already in the same thread".into()));
}
// Generated text is rebuilt from its source data. Merging body text into it would
// silently discard that text on the next update (endnotes update after every edit).
let generated = |sid| {
self.endnote_story == Some(sid)
|| self.toc.as_ref().is_some_and(|t| t.story == sid)
|| self.index.as_ref().is_some_and(|i| i.story == sid)
};
let (fs, ts) = (story(from), story(to));
if fs.is_some_and(generated) && ts.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty()) {
return Err(DocError::Invalid("cannot merge body text into a generated story".into()));
}
if ts.is_some_and(generated) && fs.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty() || st.frames != [from]) {
return Err(DocError::Invalid("a generated story can only take a new empty first frame".into()));
}
let keep_to = fs.is_none_or(|sid| self.story(sid).is_some_and(|st| st.text.is_empty() && st.frames == [from]))
&& ts.and_then(|sid| self.story(sid)).is_some_and(|st| st.frames.first() == Some(&to));
self.check_thread_links(from, to, keep_to)?;
if self.prev_frame(to).is_some() {
return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first")));
}
@@ -502,5 +582,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
return true;
}
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner)
// Type on a path: the letters stand over the path, so a click on them hits it too.
let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 };
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner)
}
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
/// either side (the type may sit on or under the path).
const PATH_TYPE_REACH: f64 = 12.0;
+21
View File
@@ -21,3 +21,24 @@ id_type!(SpreadId, "sp");
id_type!(PageId, "p");
id_type!(LayerId, "l");
id_type!(AssetId, "a");
/// Fresh story-local id. Keep monotonically increasing ids normally; a loaded MAX id must
/// not make the next insertion overwrite it. In that case choose the first unused gap.
pub(crate) fn next_local_id(ids: impl Iterator<Item = u64> + Clone) -> u64 {
if let Some(next) = ids.clone().max().unwrap_or(0).checked_add(1) {
return next;
}
let mut used: Vec<_> = ids.collect();
used.sort_unstable();
let mut next = 1u64;
for id in used {
if id > next {
break;
}
if id == next {
// An allocated Vec cannot contain every u64 id, so this always finds a gap.
next = next.saturating_add(1);
}
}
next
}
+5 -1
View File
@@ -349,8 +349,12 @@ pub struct TextFrameOptions {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PathType {
/// Where the text starts, as a distance along the path.
/// Where the text starts, as a distance along the path (the start bracket).
pub start: f64,
/// Where the text ends, as a distance along the path (the end bracket); `None` is the path's
/// end. Text past it is overset.
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<f64>,
/// Run the other way (the text sits on the other side).
pub flip: bool,
pub align: PathAlign,
+10
View File
@@ -446,6 +446,16 @@ pub struct Asset {
pub page: u32,
}
impl Asset {
/// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG.
pub fn is_raster(&self) -> bool {
matches!(
self.mime.as_str(),
"image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop"
)
}
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
+1 -1
View File
@@ -178,7 +178,7 @@ impl Story {
}
pub(crate) fn next_note_id(&self) -> u64 {
self.notes.iter().map(|n| n.id).max().unwrap_or(0).saturating_add(1)
crate::ids::next_local_id(self.notes.iter().map(|n| n.id))
}
/// Insert empty footnotes for `n` new reference characters before which `k` references sit.
+17 -1
View File
@@ -246,7 +246,8 @@ impl Page {
/// Split `area` into `n` equal columns separated by `gutter`.
pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> {
let n = n.max(1) as usize;
// The public allocator also receives native/imported options, outside command validation.
let n = n.clamp(1, 216) as usize;
let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0);
(0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect()
}
@@ -403,3 +404,18 @@ mod tests {
assert_eq!(s.page_at_x(5000.0), Some(1));
}
}
#[cfg(test)]
mod column_allocation_safety_tests {
use super::*;
#[test]
fn column_allocation_caps_page_count_and_preserves_geometry() {
let area = Rect::new(0., 0., 300., 100.);
// Fail safely at a small boundary before exercising hostile counts.
assert_eq!(column_rects(area, 217, 0.).len(), 216);
assert_eq!(column_rects(area, u32::MAX, 0.).len(), 216);
assert_eq!(column_rects(area, 0, 12.), vec![area]);
assert_eq!(column_rects(area, 2, 12.), vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)]);
}
}
+39 -4
View File
@@ -141,15 +141,20 @@ impl Story {
return;
}
if !other.tables.is_empty() {
let mut next = self.tables.keys().max().copied().unwrap_or(0).saturating_add(1);
let mut ids = std::collections::HashMap::new();
let mut next = crate::ids::next_local_id(self.tables.keys().copied());
for (old, t) in std::mem::take(&mut other.tables) {
let new = next;
next = next.saturating_add(1);
let mut t = Arc::unwrap_or_clone(t);
t.id = new;
ids.insert(old, new);
self.tables.insert(new, Arc::new(t));
// Continue through free ids without rescanning the table map for its maximum.
// On exhaustion wrap to gaps; an allocated map cannot occupy every u64 id.
next = next.checked_add(1).unwrap_or(1);
while self.tables.contains_key(&next) {
next = next.checked_add(1).unwrap_or(1);
}
}
for p in &mut other.paras {
p.table = p.table.and_then(|id| ids.get(&id).copied());
@@ -168,11 +173,11 @@ impl Story {
self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.endnotes {
let id = self.endnotes.iter().map(|x| x.id).max().unwrap_or(0).saturating_add(1);
let id = crate::ids::next_local_id(self.endnotes.iter().map(|x| x.id));
self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.editorial {
let id = self.editorial.iter().map(|x| x.id).max().unwrap_or(0).saturating_add(1);
let id = crate::ids::next_local_id(self.editorial.iter().map(|x| x.id));
self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() }));
}
self.anchors.extend(other.anchors);
@@ -215,6 +220,36 @@ mod tests {
assert!(a.extract(2..8).notes.is_empty());
}
#[test]
fn append_tables_preserves_sparse_and_wrapping_ids() {
for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] {
let story = |id, ids: &[u64]| {
let mut s = Story::with_text(StoryId(id), &vec![crate::TABLE_ANCHOR.to_string(); ids.len()].join("\n"), ParaFormat::default());
for (p, &tid) in s.paras.iter_mut().zip(ids) {
p.table = Some(tid);
let mut t = crate::table::Table::new(tid, 1, 1, 0, 0, 100.0);
t.cells[0].text = Story::with_text(StoryId(0), &format!("{id}:{tid}"), ParaFormat::default());
s.tables.insert(tid, Arc::new(t));
}
s
};
let mut a = story(1, &existing);
let b = story(2, &[1, 2, 3]);
a.check().unwrap();
b.check().unwrap();
a.append_story(b);
a.check().unwrap();
assert_eq!(a.tables.len(), 6);
for (p, tid) in a.paras.iter().take(3).zip(&existing) {
assert_eq!(p.table, Some(*tid));
assert_eq!(a.tables[tid].cells[0].text.text, format!("1:{tid}"));
}
for (p, tid) in a.paras.iter().skip(3).zip(1..=3) {
assert_eq!(a.tables[&p.table.unwrap()].cells[0].text.text, format!("2:{tid}"));
}
}
}
#[test]
fn append_story_with_maximal_ids_does_not_overflow() {
let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default());
+162 -3
View File
@@ -695,6 +695,79 @@ impl Table {
self.set_regions(&regions);
}
/// Split each original owner touched by `range` once. Remember merged-cell cuts in the
/// original grid: a neighboring split may refine that grid before we reach the owner.
pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> {
if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() {
return Err("cell range out of bounds".into());
}
let owners = self.owners();
let mut selected = std::collections::BTreeSet::new();
for r in range.r0..=range.r1 {
for c in range.c0..=range.c1 {
let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?;
selected.insert(*owner);
}
}
let mut targets = Vec::new();
for (r, c) in selected {
let cell = self.cell(r, c).ok_or("missing split cell")?;
let span = if horizontal { cell.row_span } else { cell.col_span } as usize;
let start = if horizontal { r } else { c };
let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None };
targets.push((r, c, cut));
}
for i in 0..targets.len() {
let (r, c, cut) = *targets.get(i).ok_or("missing split target")?;
let cell = self.cell(r, c).ok_or("missing split cell")?;
let (rs, cs) = (cell.row_span as usize, cell.col_span as usize);
let mut blank = cell.blank_like();
// Splitting creates an empty half, not a duplicate of graphic-cell content.
blank.graphic = None;
let before = if horizontal { self.nrows() } else { self.ncols() };
if let Some(cut) = cut {
let (start, span) = if horizontal { (r, rs) } else { (c, cs) };
let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?;
let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect();
if horizontal {
regions.push((r, c, first, cs));
regions.push((cut, c, rs - first, cs));
} else {
regions.push((r, c, rs, first));
regions.push((r, cut, rs, cs - first));
}
self.set_regions(&regions);
} else {
// An originally single-track cell may now span two tracks inserted for its
// neighbor. split_cell divides that span instead of inserting another track.
self.split_cell(r, c, horizontal);
}
let first = self.cell(r, c).ok_or("missing split half")?;
let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) };
let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?;
blank.row_span = second.row_span;
blank.col_span = second.col_span;
*second = blank;
let after = if horizontal { self.nrows() } else { self.ncols() };
if after > before {
let at = if horizontal { r + 1 } else { c + 1 };
let added = after - before;
for (row, col, cut) in targets.iter_mut().skip(i + 1) {
let axis = if horizontal { row } else { col };
if *axis >= at {
*axis += added;
}
if let Some(cut) = cut
&& *cut >= at
{
*cut += added;
}
}
}
}
Ok(())
}
/// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell
/// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a
/// new row (column) that the cells beside it span, the row height (column width) shared.
@@ -719,10 +792,11 @@ impl Table {
return;
}
if horizontal {
let h = self.rows[or].height;
let row = self.rows[or].clone();
self.insert_rows(or + 1, 1);
self.rows[or].height = h / 2.0;
self.rows[or + 1].height = h / 2.0;
self.rows[or].height = row.height / 2.0;
// Both halves keep the split row's height mode and header/body/footer role.
self.rows[or + 1] = Row { height: row.height / 2.0, ..row };
// insert_rows doesn't grow regions that end at the split row; everything else on it
// spans the new row now.
let mut regions: Vec<_> = self.regions();
@@ -1255,4 +1329,89 @@ mod split_tests {
assert_eq!(m.nrows(), 4);
assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2));
}
#[test]
fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() {
for horizontal in [true, false] {
let mut t = Table::new(1, 4, 4, 0, 0, 280.0);
for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() {
t.rows[i].mode = RowHeightMode::Exactly;
t.rows[i].height = if horizontal { size } else { 70.0 };
t.columns[i].width = if horizontal { 70.0 } else { size };
}
for r in 0..4 {
for c in 0..4 {
let cell = t.cell_mut(r, c).unwrap();
cell.text.insert(0, &format!("{r},{c}"));
cell.style = format!("style-{r}-{c}");
cell.insets = [r as f64 + c as f64 + 1.0; 4];
}
}
let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal {
(CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3))
} else {
(CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6))
};
t.merge(merged).unwrap();
let original = t.clone();
t.split_cells(selected, horizontal).unwrap();
t.check().unwrap();
assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) });
let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() };
assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]);
let first = t.cell(owner.0, owner.1).unwrap();
let second = t.cell(lower.0, lower.1).unwrap();
// The original three-track merge divides after its second original track,
// regardless of which of those tracks a neighboring selected cell subdivides.
assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4);
assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2);
assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text);
assert!(second.text.is_empty());
assert_eq!(second.style, first.style);
assert_eq!(second.insets, first.insets);
assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1));
let owners = t.owners();
assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 18, "four original selected owners split once each");
let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect();
let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect();
before_text.sort();
after_text.sort();
assert_eq!(after_text, before_text, "no original text is copied or lost");
}
}
#[test]
fn split_range_resolves_covered_positions_without_expanding_selection() {
for horizontal in [true, false] {
let mut t = Table::new(1, 3, 3, 0, 0, 180.0);
t.merge(CellRange::new(0, 0, 1, 1)).unwrap();
let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) };
t.split_cells(selected, horizontal).unwrap();
t.check().unwrap();
let owners = t.owners();
assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 8, "only the two touched owners split");
assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) });
assert_eq!(t.owner(0, 0), (0, 0));
assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) });
}
}
#[test]
fn split_header_row_keeps_its_height_mode_and_role() {
let mut t = Table::new(1, 1, 2, 1, 1, 180.0);
t.rows[0].height = 60.0;
t.rows[0].mode = RowHeightMode::Exactly;
let body = t.rows[1].clone();
let footer = t.rows[2].clone();
t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap();
t.check().unwrap();
assert_eq!(t.header_rows(), 2);
for row in &t.rows[..2] {
assert_eq!(row.height, 30.0);
assert_eq!(row.mode, RowHeightMode::Exactly);
assert_eq!(row.kind, RowKind::Header);
}
assert_eq!(t.rows[2], body);
assert_eq!(t.rows[3], footer);
}
}
+359
View File
@@ -0,0 +1,359 @@
//! Crash-safe file replacement: how documents, exports and other user files reach the disk.
//!
//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary
//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then
//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename
//! fails, the temporary file is removed and the destination keeps its old bytes.
//!
//! - A replaced file keeps its permissions (Unix mode).
//! - A symbolic link keeps pointing at its file: the file it names is replaced.
//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the
//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff.
//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no
//! fallback to writing in place.
//! - Flushing the parent folder after replacement is best effort. A successful save confirms
//! that the new file is in place; it cannot guarantee survival of a subsequent power loss if
//! the file system rejects that folder flush.
//!
//! The web has no file system, so this module is not built for `wasm32`.
use std::fs::File;
#[cfg(unix)]
use std::fs::OpenOptions;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
/// How many times a replacing rename is tried before its error is returned.
const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 };
/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`].
const RENAME_FIRST_DELAY_MS: u64 = 10;
const RENAME_MAX_DELAY_MS: u64 = 200;
/// Symbolic links followed before giving up (a loop, or an absurd chain).
const MAX_LINKS: usize = 40;
/// Atomically replace (or create) `path` with `bytes`. See the module docs.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, |f| f.write_all(bytes))
}
/// [`write_atomic`] for a command's `path` parameter: the error names the path.
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> {
write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))
}
/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When
/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and
/// the error is returned.
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_links(path)?;
target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
let dir = match target.parent() {
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
_ => PathBuf::from("."),
};
let existing = std::fs::metadata(&target).ok();
if existing.is_some() {
check_writable(&target)?;
}
let mut tmp = create_temp(&dir, existing.as_ref())?;
if let Some(existing) = existing {
// Restore the exact mode before any document bytes are written, and flush it along with
// the content. The caller's umask may have restricted the initial temporary-file mode.
keep_permissions(tmp.as_file(), &existing)?;
}
fill(tmp.as_file_mut())?;
tmp.as_file().sync_all()?;
// Closing the file before renaming also allows replacement on Windows. TempPath removes the
// staging file on every error; NamedTempFile does the same if the writer unwinds.
let tmp = tmp.into_temp_path();
retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target))?;
// The new bytes are in place. A failed folder flush can't be undone and must not report the
// save as failed.
let _ = sync_dir(&dir);
Ok(())
}
/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one
/// volume). A short randomized name supports long destination names and skips existing entries.
fn create_temp(dir: &Path, existing: Option<&std::fs::Metadata>) -> io::Result<tempfile::NamedTempFile> {
let mut builder = tempfile::Builder::new();
builder.prefix(".designcraft-save-").suffix(".tmp");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Private replacements are private from creation. New files retain fs::write's normal
// 0666 mode restricted by the caller's umask.
let mode = existing.map_or(0o666, |metadata| metadata.permissions().mode());
builder.permissions(std::fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
let _ = existing;
builder.tempfile_in(dir)
}
/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling
/// link resolves to the missing file it names, which is then created.
fn resolve_links(path: &Path) -> io::Result<PathBuf> {
let mut p = path.to_path_buf();
for _ in 0..MAX_LINKS {
match std::fs::symlink_metadata(&p) {
Ok(m) if m.file_type().is_symlink() => {
let to = std::fs::read_link(&p)?;
p = match p.parent() {
Some(dir) if to.is_relative() => dir.join(to),
_ => to,
};
}
_ => return Ok(p),
}
}
Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display())))
}
/// A rename replaces a file the user can't write (Unix only needs the folder to be writable);
/// refuse, as writing it in place would. Opening for writing without truncating changes nothing.
#[cfg(unix)]
fn check_writable(target: &Path) -> io::Result<()> {
OpenOptions::new().write(true).open(target).map(drop)
}
/// Windows refuses to rename over a read-only file by itself.
#[cfg(not(unix))]
fn check_writable(_: &Path) -> io::Result<()> {
Ok(())
}
/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag,
/// and a read-only file can't be replaced).
#[cfg(unix)]
fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> {
tmp.set_permissions(replaced.permissions())
}
#[cfg(not(unix))]
fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> {
Ok(())
}
/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with
/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once.
fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
let mut delay = RENAME_FIRST_DELAY_MS;
let mut left = tries.max(1);
loop {
left = left.saturating_sub(1);
match rename() {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => {
std::thread::sleep(std::time::Duration::from_millis(delay));
delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS);
}
r => return r,
}
}
}
/// Flush a folder's entries (Unix). Rust's portable file API cannot open a directory on Windows;
/// this does not establish power-loss durability for its directory entries.
#[cfg(unix)]
fn sync_dir(dir: &Path) -> io::Result<()> {
File::open(dir)?.sync_all()
}
#[cfg(not(unix))]
fn sync_dir(_: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The names in `d`, sorted.
fn names(d: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
v.sort();
v
}
#[test]
fn replaces_and_creates_without_leftovers() {
let d = dir("replace");
let path = d.join("doc.designcraft");
write_atomic(&path, b"first").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"first");
write_atomic(&path, b"second, longer").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second, longer");
write_atomic(&path, b"3").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"3");
assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_failed_write_leaves_the_original_and_no_temporary_file() {
let d = dir("fail");
let path = d.join("doc.designcraft");
std::fs::write(&path, b"original").unwrap();
let r = write_atomic_with(&path, |f| {
f.write_all(b"half a docu")?;
Err(io::Error::other("disk full"))
});
assert_eq!(r.unwrap_err().to_string(), "disk full");
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed");
// A folder that doesn't exist: an error, nothing written.
assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err());
assert_eq!(names(&d), ["doc.designcraft"]);
// A folder in the way: an error, the folder stays and nothing is left beside it.
std::fs::create_dir(d.join("folder")).unwrap();
assert!(write_atomic(&d.join("folder"), b"x").is_err());
assert_eq!(names(&d), ["doc.designcraft", "folder"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_long_destination_name_does_not_lengthen_the_temporary_name() {
let d = dir("long-name");
let name = format!("{}.designcraft", "a".repeat(235));
let path = d.join(&name);
std::fs::write(&path, b"old").unwrap();
write_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(names(&d), [name]);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn private_permissions_are_preserved_while_the_file_is_written() {
use std::os::unix::fs::PermissionsExt as _;
let d = dir("private-staging");
let path = d.join("private.designcraft");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic_with(&path, |file| {
assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600);
file.write_all(b"private document")?;
assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600);
Ok(())
})
.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"private document");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(names(&d), ["private.designcraft"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_unwinding_writer_removes_its_partial_temporary_file() {
let d = dir("unwinding-writer");
let path = d.join("private.designcraft");
std::fs::write(&path, b"old").unwrap();
let result = std::panic::catch_unwind(|| {
write_atomic_with(&path, |file| {
file.write_all(b"partial private document")?;
panic!("synthetic writer panic");
})
});
assert!(result.is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"old");
assert_eq!(names(&d), ["private.designcraft"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_rename_is_retried_only_while_access_is_denied() {
let mut calls = 0;
let r = retry_rename(3, || {
calls += 1;
if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) }
});
assert!(r.is_ok() && calls == 3);
calls = 0;
let r = retry_rename(3, || {
calls += 1;
Err(io::Error::from(io::ErrorKind::PermissionDenied))
});
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied));
assert_eq!(calls, 3, "gives up after the last try");
calls = 0;
let r = retry_rename(3, || {
calls += 1;
Err(io::Error::from(io::ErrorKind::CrossesDevices))
});
assert!(r.is_err() && calls == 1, "other errors are not retried");
}
#[cfg(unix)]
#[test]
fn permissions_are_kept_and_unwritable_files_are_not_replaced() {
use std::os::unix::fs::PermissionsExt as _;
let d = dir("perms");
let path = d.join("private.designcraft");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
// A read-only file stays as it is (root may write it anyway).
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
if OpenOptions::new().write(true).open(&path).is_err() {
assert!(write_atomic(&path, b"newer").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(names(&d), ["private.designcraft"]);
}
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
let _ = std::fs::remove_dir_all(&d);
}
/// A folder the user can't write: the save fails and the original stays.
#[cfg(unix)]
#[test]
fn an_unwritable_folder_fails_without_touching_the_file() {
use std::os::unix::fs::PermissionsExt as _;
let d = dir("ro-dir");
let path = d.join("doc.designcraft");
std::fs::write(&path, b"original").unwrap();
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap();
// Root writes anyway; the check only means something for an ordinary user.
let probe = d.join("probe");
let unwritable = std::fs::write(&probe, b"").is_err();
if unwritable {
assert!(write_atomic(&path, b"new").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"original");
}
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap();
let _ = std::fs::remove_file(&probe);
assert_eq!(names(&d), ["doc.designcraft"]);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_symbolic_link_keeps_pointing_at_the_replaced_file() {
let d = dir("link");
std::fs::create_dir(d.join("real")).unwrap();
let target = d.join("real").join("doc.designcraft");
std::fs::write(&target, b"old").unwrap();
let link = d.join("link.designcraft");
std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap();
write_atomic(&link, b"new").unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert_eq!(names(&d), ["link.designcraft", "real"]);
assert_eq!(names(&d.join("real")), ["doc.designcraft"]);
// A loop of links is an error, not a hang.
std::os::unix::fs::symlink("b", d.join("a")).unwrap();
std::os::unix::fs::symlink("a", d.join("b")).unwrap();
assert!(write_atomic(&d.join("a"), b"x").is_err());
let _ = std::fs::remove_dir_all(&d);
}
}
+178 -56
View File
@@ -28,7 +28,7 @@ fn no_book() -> EngineError {
#[cfg(not(target_arch = "wasm32"))]
fn save(b: &Book) -> Result<()> {
let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?;
std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path)))
crate::atomic::write_file(&b.path, &bytes)
}
#[cfg(target_arch = "wasm32")]
@@ -47,20 +47,176 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
if path.to_lowercase().ends_with(".idml") {
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
}
super::file::from_bytes(&bytes)
super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}")));
#[cfg(target_arch = "wasm32")]
{
let _ = (path, d);
Err(EngineError::Other("books need a file system".into()))
#[cfg(not(target_arch = "wasm32"))]
const MAX_BATCH_CHAPTERS: usize = 4096;
/// A batch keeps only bounded path records in memory. Chapter snapshots live in a private
/// temporary directory; only one document and serialized blob are live while staging.
#[cfg(not(target_arch = "wasm32"))]
struct ChapterBatch {
directory: tempfile::TempDir,
chapters: Vec<(String, tempfile::TempPath)>,
command: &'static str,
}
#[cfg(not(target_arch = "wasm32"))]
impl ChapterBatch {
fn new(chapters: usize, command: &'static str) -> Result<Self> {
if chapters > MAX_BATCH_CHAPTERS {
return Err(bad(command, format!("a book update supports at most {MAX_BATCH_CHAPTERS} chapters")));
}
let directory = tempfile::Builder::new()
.prefix(".designcraft-book-")
.tempdir()
.map_err(|e| EngineError::Other(format!("{command}: cannot stage chapter changes: {e}")))?;
Ok(Self { directory, chapters: Vec::with_capacity(chapters), command })
}
fn stage(&mut self, path: &str, doc: &designcraft_doc::Document) -> Result<()> {
use std::io::Write;
// Propagate serialization errors; an empty fallback must never replace a chapter.
let bytes = designcraft_format::save(doc).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut file = tempfile::Builder::new()
.prefix("chapter-")
.tempfile_in(self.directory.path())
.map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?;
file.write_all(&bytes).map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?;
self.chapters.push((path.to_string(), file.into_temp_path()));
Ok(())
}
fn commit(self) -> Result<()> {
self.commit_with(write_staged_chapter)
}
fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> {
let mut completed = Vec::with_capacity(self.chapters.len());
for (path, staged) in self.chapters {
if let Err(e) = write(std::path::Path::new(&path), &staged) {
// Each file is atomic; the whole book is not. Keep the successful prefix visible
// rather than pretending that an error undid earlier chapter replacements.
return Err(EngineError::Other(format!(
"{}: failed to save chapter {path}: {e}; completed chapter paths: {completed:?}",
self.command
)));
}
completed.push(path);
}
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> {
let mut input = std::fs::File::open(staged)?;
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
}
/// Batch commands only write closed native chapters. IDML is an imported source, and an open
/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update.
#[cfg(not(target_arch = "wasm32"))]
fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> {
use std::path::Path;
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
}
let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) {
return Err(bad(command, format!("chapter {path} is open; close it before updating the book")));
}
if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) {
return Err(bad(command, format!("chapter {path} is not a regular file")));
}
// Check both the file and its resolved parent without truncating it. Replacement needs a
// writable directory even when an in-place write would be permitted.
std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?;
tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn paginate(s: &Session) -> Result<Value> {
let b = s.book.as_ref().ok_or_else(no_book)?;
if b.documents.is_empty() {
return Ok(json!({"pages": 0}));
}
let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?;
let mut next = 1u32;
for path in &b.documents {
writable_chapter(s, path, "book.paginate")?;
let mut d = load(path)?;
start_at(&mut d, next);
let pages = u32::try_from(d.page_count()).map_err(|_| bad("book.paginate", "too many pages for continuous numbering"))?;
next = next.checked_add(pages).ok_or_else(|| bad("book.paginate", "too many pages for continuous numbering"))?;
batch.stage(path, &d)?;
}
batch.commit()?;
Ok(json!({"pages": next - 1}))
}
#[cfg(not(target_arch = "wasm32"))]
fn sync_styles(s: &Session) -> Result<Value> {
let b = s.book.as_ref().ok_or_else(no_book)?;
let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?;
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
let src = load(src_path)?;
let styles = src.styles.clone();
let swatches = src.swatches.clone();
drop(src);
let mut n = 0;
for (i, path) in b.documents.iter().enumerate() {
if i == b.style_source {
continue;
}
writable_chapter(s, path, "book.syncStyles")?;
let mut d = load(path)?;
{
let st = d.styles_mut();
for ps in &styles.paragraph {
st.paragraph.retain(|x| x.name != ps.name);
st.paragraph.push(ps.clone());
}
for cs in &styles.character {
st.character.retain(|x| x.name != cs.name);
st.character.push(cs.clone());
}
}
for sw in &swatches {
match d.swatches.iter_mut().find(|w| w.name == sw.name) {
Some(w) if !w.locked => *w = sw.clone(),
Some(_) => {}
None => d.swatches.push(sw.clone()),
}
}
batch.stage(path, &d)?;
n += 1;
}
batch.commit()?;
Ok(json!({"synced": n}))
}
#[cfg(target_arch = "wasm32")]
fn paginate(_: &Session) -> Result<Value> {
Err(EngineError::Other("books need a file system".into()))
}
#[cfg(target_arch = "wasm32")]
fn sync_styles(_: &Session) -> Result<Value> {
Err(EngineError::Other("books need a file system".into()))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "book/safety_tests.rs"]
mod safety_tests;
/// Make page numbering of `d` start at `n` (the first section).
#[cfg(not(target_arch = "wasm32"))]
fn start_at(d: &mut designcraft_doc::Document, n: u32) {
if !d.sections.iter().any(|x| x.start == 0) {
d.sections.insert(
@@ -108,15 +264,16 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "book.add", "Add Document", [], None, "{path, at?: index}", has_book, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("book.add", "`path` required"))?.to_string();
load(&path)?;
let b = s.book.as_mut().ok_or_else(no_book)?;
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let at = p.get("at").and_then(Value::as_u64).map_or(b.documents.len(), |i| (i as usize).min(b.documents.len()));
b.documents.insert(at, path);
save(b)?;
save(&b)?;
s.book = Some(b);
ok()
}),
cmd!(noundo "book.remove", "Remove Document", [], None, "{index}", has_book, |s, p| {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("book.remove", "`index` required"))? as usize;
let b = s.book.as_mut().ok_or_else(no_book)?;
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
if i >= b.documents.len() {
return Err(bad("book.remove", format!("no document {i}")));
}
@@ -124,17 +281,19 @@ pub fn specs() -> Vec<CommandSpec> {
if b.style_source >= b.documents.len() {
b.style_source = 0;
}
save(b)?;
save(&b)?;
s.book = Some(b);
ok()
}),
cmd!(noundo "book.styleSource", "Style Source", [], None, "{index}", has_book, |s, p| {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = s.book.as_mut().ok_or_else(no_book)?;
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
if i >= b.documents.len() {
return Err(bad("book.styleSource", format!("no document {i}")));
}
b.style_source = i;
save(b)?;
save(&b)?;
s.book = Some(b);
ok()
}),
cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| {
@@ -149,48 +308,10 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs}))
}),
cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| {
let b = s.book.clone().ok_or_else(no_book)?;
let mut next = 1u32;
for p in &b.documents {
let mut d = load(p)?;
start_at(&mut d, next);
next += d.page_count() as u32;
store(p, &d)?;
}
Ok(json!({"pages": next - 1}))
paginate(s)
}),
cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| {
let b = s.book.clone().ok_or_else(no_book)?;
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
let src = load(src_path)?;
let mut n = 0;
for (i, p) in b.documents.iter().enumerate() {
if i == b.style_source {
continue;
}
let mut d = load(p)?;
{
let st = d.styles_mut();
for ps in &src.styles.paragraph {
st.paragraph.retain(|x| x.name != ps.name);
st.paragraph.push(ps.clone());
}
for cs in &src.styles.character {
st.character.retain(|x| x.name != cs.name);
st.character.push(cs.clone());
}
}
for sw in &src.swatches {
match d.swatches.iter_mut().find(|w| w.name == sw.name) {
Some(w) if !w.locked => *w = sw.clone(),
Some(_) => {}
None => d.swatches.push(sw.clone()),
}
}
store(p, &d)?;
n += 1;
}
Ok(json!({"synced": n}))
sync_styles(s)
}),
cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| {
let b = s.book.clone().ok_or_else(no_book)?;
@@ -210,7 +331,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
@@ -235,6 +356,7 @@ mod tests {
s.execute("file.new", &json!({"pages": pages})).unwrap();
s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap();
s.execute("file.saveAs", &json!({"path": p(name)})).unwrap();
s.execute("file.close", &json!({})).unwrap();
}
s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap();
s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap();
@@ -0,0 +1,52 @@
use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter};
use crate::Session;
use serde_json::json;
#[test]
fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first.designcraft");
let second = dir.path().join("second.designcraft");
let third = dir.path().join("third.designcraft");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let old = (*s.doc().unwrap().doc).clone();
let mut changed = old.clone();
changed.sections.first_mut().unwrap().start_number = Some(9);
let old_bytes = designcraft_format::save(&old).unwrap();
for path in [&first, &second, &third] {
std::fs::write(path, &old_bytes).unwrap();
}
let mut batch = ChapterBatch::new(3, "book.paginate").unwrap();
for path in [&first, &second, &third] {
batch.stage(path.to_str().unwrap(), &changed).unwrap();
}
let staging = batch.directory.path().to_path_buf();
let mut calls = 0;
let error = batch
.commit_with(|path, staged| {
calls += 1;
if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) }
})
.unwrap_err()
.to_string();
assert_eq!(calls, 2);
assert!(error.contains("synthetic disk full"), "{error}");
assert!(error.contains(second.to_str().unwrap()), "{error}");
let completed = error.split("completed chapter paths:").nth(1).unwrap();
assert!(completed.contains(first.to_str().unwrap()), "{error}");
assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}");
assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9));
assert_eq!(std::fs::read(second).unwrap(), old_bytes);
assert_eq!(std::fs::read(third).unwrap(), old_bytes);
assert!(!staging.exists(), "temporary chapter snapshots must be removed on a partial failure");
}
#[test]
fn chapter_record_limit_is_checked_before_staging_allocation() {
let error = match ChapterBatch::new(MAX_BATCH_CHAPTERS + 1, "book.paginate") {
Err(e) => e,
Ok(_) => panic!("oversized book batch accepted"),
};
assert!(error.to_string().contains("4096"));
}
+93 -13
View File
@@ -367,10 +367,16 @@ fn sniff(name: &str, text: &str) -> Delimiter {
if ext == "csv" {
return Delimiter::Comma;
}
let header = first_physical_line(text);
if header.contains('\t') {
let tab = header_delimiter_candidate(text, '\t');
let semicolon = header_delimiter_candidate(text, ';');
let comma = header_delimiter_candidate(text, ',');
// Prefer formats with coherent quoted-field boundaries. If none separates fields,
// retain the legacy permissive choice; this is detection, not record validation.
let prefer_coherent = [&tab, &semicolon, &comma].iter().any(|c| c.found && c.coherent);
let eligible = |c: &HeaderDelimiterCandidate| c.found && (!prefer_coherent || c.coherent);
if eligible(&tab) {
Delimiter::Tab
} else if header.contains(';') && !header.contains(',') {
} else if eligible(&semicolon) && !eligible(&comma) {
Delimiter::Semicolon
} else {
Delimiter::Comma
@@ -381,8 +387,66 @@ pub fn extension(name: &str) -> String {
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
fn first_physical_line(text: &str) -> &str {
text.split('\n').next().unwrap_or("").trim_end_matches('\r')
struct HeaderDelimiterCandidate {
found: bool,
coherent: bool,
}
/// A candidate separator outside quotes in its first nonempty logical record.
/// Only that candidate starts a new field. Quotes in an unquoted field stay literal;
/// Non-whitespace text after a quoted field closes lowers coherence, not validity.
fn header_delimiter_candidate(text: &str, delimiter: char) -> HeaderDelimiterCandidate {
let mut found = false;
let mut coherent = true;
let mut quoted = false;
let mut after_quote = false;
let mut field_start = true;
let mut nonempty = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if quoted {
if c == '"' {
if chars.peek() == Some(&'"') {
chars.next();
nonempty = true;
} else {
quoted = false;
after_quote = true;
}
} else if !c.is_whitespace() {
nonempty = true;
}
continue;
}
match c {
'"' if field_start => {
quoted = true;
field_start = false;
}
separator if separator == delimiter => {
found = true;
field_start = true;
after_quote = false;
}
'\r' | '\n' => {
if nonempty {
break;
}
found = false;
coherent = true;
field_start = true;
after_quote = false;
}
_ => {
// Whitespace may follow a closing quote, but does not start a new field
// or allow a later non-whitespace suffix to regain coherence.
coherent &= !after_quote || c.is_whitespace();
nonempty |= !c.is_whitespace();
field_start = false;
}
}
}
HeaderDelimiterCandidate { found, coherent: coherent && !quoted }
}
/// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr.
@@ -452,6 +516,7 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
let mut row: Vec<String> = Vec::new();
let mut field = String::new();
let mut quoted = false;
let mut field_start = true;
let mut line = 1usize;
let mut row_line = 1usize;
let mut data_rows = 0usize;
@@ -501,21 +566,25 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
};
while let Some(c) = chars.next() {
if c == '\n' {
if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) {
line = line.saturating_add(1);
}
if quoted {
match c {
'"' if chars.peek() == Some(&'"') => {
field.push('"');
if !discarding {
field.push('"');
}
chars.next();
}
'"' => quoted = false,
_ => field.push(c),
_ if !discarding => field.push(c),
_ => {}
}
continue;
}
if c == delim {
field_start = true;
if discarding {
field.clear();
continue;
@@ -531,14 +600,25 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
continue;
}
match c {
'"' if field.is_empty() && !discarding => quoted = true,
'\r' => {}
'\n' => {
'"' if field_start => {
quoted = true;
field_start = false;
}
'\r' | '\n' => {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
line = line.saturating_add(1);
}
finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?;
row_line = line;
field_start = true;
}
_ => {
field_start = false;
if !discarding {
field.push(c);
}
}
_ if discarding => {}
_ => field.push(c),
}
}
if !field.is_empty() || !row.is_empty() || discarding {
+276
View File
@@ -85,6 +85,282 @@ fn csv_parsing() {
assert_eq!(quoted[1][0], "Ada\nGrace");
}
#[test]
fn data_source_sniffs_quoted_logical_headers() {
for (body, delimiter, names) in [
("\"Last, First\";City\nAda;London\n", Delimiter::Semicolon, ["Last, First", "City"]),
("\"Name\tLabel\",City\nAda,London\n", Delimiter::Comma, ["Name\tLabel", "City"]),
("\"Name\nLabel\";City\nAda;London\n", Delimiter::Semicolon, ["Name\nLabel", "City"]),
("\r\n \r\nName;City\r\nAda;London\r\n", Delimiter::Semicolon, ["Name", "City"]),
("\"Name \"\"label,\"\"\";City\nAda;London\n", Delimiter::Semicolon, ["Name \"label,\"", "City"]),
] {
let mut s = Session::new();
new_doc(&mut s, 1);
let result = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
assert_eq!(result["records"], 1, "{body:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.delimiter, delimiter, "{body:?}");
assert_eq!(source.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), names, "{body:?}");
assert_eq!(source.rows, vec![vec!["Ada", "London"]], "{body:?}");
}
}
#[test]
fn data_source_sniff_quote_state_belongs_to_the_candidate_delimiter() {
for ending in ["\n", "\r\n", "\r"] {
for (body, delimiter, names) in [
(format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), Delimiter::Tab, ["Name,\"preferred", "City\""]),
(format!("\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]),
(format!("\"Name\tLabel\",City{ending}Ada,London{ending}"), Delimiter::Comma, ["Name\tLabel", "City"]),
(format!("\"\";\"\"{ending} \t{ending}\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]),
] {
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}");
assert!(automatic.warnings.is_empty(), "{body:?}: {:?}", automatic.warnings);
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_prefers_coherent_quoted_field_boundaries() {
for ending in ["\n", "\r\n", "\r"] {
for (header, first, second, delimiter, names, rows, warnings) in [
(
"\"Name\";\"Last, First\"",
"\"Ada\";\"Lovelace, Countess\"",
"\"Grace\";\"Hopper, Admiral\"",
Delimiter::Semicolon,
vec!["Name", "Last, First"],
vec![vec!["Ada", "Lovelace, Countess"], vec!["Grace", "Hopper, Admiral"]],
vec![],
),
(
"\"Name\",\"Region\tCity\"",
"\"Ada\",\"London\"",
"\"Grace\",\"Paris\"",
Delimiter::Comma,
vec!["Name", "Region\tCity"],
vec![vec!["Ada", "London"], vec!["Grace", "Paris"]],
vec![],
),
(
"\"Name\";\"City\";\"Last, \"\"First\"\"\";\"Title\"",
"\"Ada\";\"London\";\"Lovelace, \"\"Countess\"\"\";\"Analyst\"",
"\"Grace\";\"Paris\";\"Hopper, \"\"Admiral\"\"\";\"Engineer\";\"ignored\"",
Delimiter::Semicolon,
vec!["Name", "City", "Last, \"First\"", "Title"],
vec![vec!["Ada", "London", "Lovelace, \"Countess\"", "Analyst"], vec!["Grace", "Paris", "Hopper, \"Admiral\"", "Engineer"]],
vec!["Row 3 has cells past the header. Those cells were ignored."],
),
(
"\"Name\",\"Role\",\"Region\t\"\"City\"\"\",\"Title\"",
"\"Ada\",\"Analyst\",\"London\t\"\"West End\"\"\",\"Countess\"",
"\"Grace\",\"Engineer\",\"Paris\t\"\"Centre\"\"\",\"Admiral\",\"ignored\"",
Delimiter::Comma,
vec!["Name", "Role", "Region\t\"City\"", "Title"],
vec![vec!["Ada", "Analyst", "London\t\"West End\"", "Countess"], vec!["Grace", "Engineer", "Paris\t\"Centre\"", "Admiral"]],
vec!["Row 3 has cells past the header. Those cells were ignored."],
),
] {
let body = format!("{header}{ending}{first}{ending}{second}{ending}");
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, rows, "{body:?}");
assert_eq!(automatic.warnings, warnings, "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_retains_priority_and_permissive_fallback() {
for ending in ["\n", "\r\n", "\r"] {
for (header, row, delimiter, names, cells) in [
// Equally coherent candidates retain Tab, then Comma, then Semicolon priority.
("Name,Title\tCity", "Ada,Analyst\tLondon\tignored", Delimiter::Tab, ["Name,Title", "City"], ["Ada,Analyst", "London"]),
("Name;Title,City", "Ada;Analyst,London,ignored", Delimiter::Comma, ["Name;Title", "City"], ["Ada;Analyst", "London"]),
// No coherent candidate separates fields here. Keep parsing the trailing text
// after a quoted field exactly as before, using the same delimiter priority.
(
"\"Name\"suffix,Title\tCity",
"\"Ada\"suffix,Analyst\tLondon\tignored",
Delimiter::Tab,
["Namesuffix,Title", "City"],
["Adasuffix,Analyst", "London"],
),
(
"\"Name\"suffix;Title,City",
"\"Ada\"suffix;Analyst,London,ignored",
Delimiter::Comma,
["Namesuffix;Title", "City"],
["Adasuffix;Analyst", "London"],
),
("\"Name\"suffix;City", "\"Ada\"suffix;London;ignored", Delimiter::Semicolon, ["Namesuffix", "City"], ["Adasuffix", "London"]),
] {
let body = format!("{header}{ending}{row}{ending}");
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, vec![cells.to_vec()], "{body:?}");
assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_allows_post_quote_whitespace() {
for ending in ["\n", "\r\n", "\r"] {
for space in [" ", "\u{00A0}", "\u{2003}"] {
for (header, separator, delimiter, names) in [
(format!("\"Name\"{space};\"Last, First\""), ';', Delimiter::Semicolon, ["Name", "Last, First"]),
(format!("\"Name\"{space},\"Region\tCity\"{space}"), ',', Delimiter::Comma, ["Name", "Region\tCity"]),
(format!("\"Name\"{space}\t\"Last, First; City\"{space}"), '\t', Delimiter::Tab, ["Name", "Last, First; City"]),
] {
let body = format!(
"{header}{ending}\"Ada\"{space}{separator}\"London\"{space}{ending}\"Grace\"{space}{separator}\"Paris\"{space}{separator}ignored{ending}"
);
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
// Header classification trims; record values must keep their whitespace.
assert_eq!(
automatic.rows,
vec![vec![format!("Ada{space}"), format!("London{space}")], vec![format!("Grace{space}"), format!("Paris{space}")]],
"{body:?}"
);
assert_eq!(automatic.warnings, vec!["Row 3 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
}
#[test]
fn data_source_sniff_post_quote_whitespace_keeps_closed_field_state() {
for ending in ["\n", "\r\n", "\r"] {
for space in [" ", "\u{00A0}", "\u{2003}"] {
for suffix in ["suffix", "\"suffix\""] {
let body = format!("\"Name\"{space}{suffix};\"Last, First\"{ending}Ada,London,ignored{ending}");
// Both separated formats have incompatible post-quote suffixes. Keep
// the permissive Comma fallback, including literal quotes in the name.
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(Delimiter::Comma)).unwrap();
assert_eq!(automatic.delimiter, Delimiter::Comma, "{body:?}");
assert_eq!(
automatic.fields,
vec![
DataField { name: format!("Name{space}{suffix};\"Last"), kind: DataFieldKind::Text },
DataField { name: "First\"".into(), kind: DataFieldKind::Text },
],
"{body:?}"
);
assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}");
assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
}
#[test]
fn data_source_literal_quote_header_merges_the_detected_column() {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = "Name,\"preferred\tCity\"\nAda\tLondon\nGrace\tParis\n";
let selected = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
assert_eq!(selected["records"], 2);
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.delimiter, Delimiter::Tab);
assert_eq!(source.rows, vec![vec!["Ada", "London"], vec!["Grace", "Paris"]]);
exec(&mut s, "data.placeholder.add", json!({"field": "City\""}));
let merged = exec(&mut s, "data.merge", json!({}));
assert_eq!(merged["records"], 2);
assert_eq!(merged["pages"], 2);
assert_eq!(texts(&s), vec!["London", "Paris"]);
}
#[test]
fn data_source_preserves_quoted_newlines_and_counts_record_lines() {
for ending in ["\n", "\r\n", "\r"] {
let body = format!("Name,City{ending}\"Ada{ending}Lovelace\",London{ending}Grace,Paris,extra{ending}");
let mut s = Session::new();
new_doc(&mut s, 1);
let result = select_csv(&mut s, &body);
assert_eq!(result["records"], 2, "{ending:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.rows, vec![vec![format!("Ada{ending}Lovelace"), "London".into()], vec!["Grace".into(), "Paris".into()]]);
assert_eq!(source.warnings.len(), 1);
assert!(source.warnings[0].contains("Row 4"), "{:?}", source.warnings);
}
}
#[test]
fn data_source_quoted_header_and_cr_records_merge_independently() {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = "\u{FEFF}\"Last, First\";City\rAda;London\rGrace;Paris\r";
exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Last, First"}));
let template = s.documents()[0].doc.clone();
let result = exec(&mut s, "data.merge", json!({}));
assert_eq!(result["records"], 2);
assert_eq!(result["pages"], 2);
assert_eq!(texts(&s), vec!["Ada", "Grace"]);
assert_eq!(serde_json::to_value(&s.documents()[0].doc).unwrap(), serde_json::to_value(&template).unwrap());
}
#[test]
fn data_source_discarded_quoted_fields_keep_records_and_warning_lines() {
for ending in ["\n", "\r\n", "\r"] {
for (discarded, following_line) in [(format!("\"Address \"\"line 1\"\"{ending}Address line 2\""), 4), ("literal\"quote".into(), 3)] {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = format!("Name{ending}Ada,,,{discarded}{ending}Grace,extra{ending}");
let selected = select_csv(&mut s, &body);
assert_eq!(selected["records"], 2, "{body:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.rows, vec![vec!["Ada"], vec!["Grace"]], "{body:?}");
assert_eq!(source.warnings.len(), 2, "{:?}", source.warnings);
assert!(source.warnings[0].contains("Row 2"), "{:?}", source.warnings);
assert!(source.warnings[1].contains(&format!("Row {following_line}")), "{:?}", source.warnings);
exec(&mut s, "data.placeholder.add", json!({"field": "Name"}));
let merged = exec(&mut s, "data.merge", json!({}));
assert_eq!(merged["records"], 2);
assert_eq!(merged["pages"], 2);
assert_eq!(texts(&s), vec!["Ada", "Grace"]);
}
}
}
#[test]
fn data_source_delimiter_override_and_known_extensions_win() {
let body = "Name;City\nAda;London\n";
let csv = parse::table_from_bytes(body.as_bytes(), "people.csv", None).unwrap();
assert_eq!(csv.delimiter, Delimiter::Comma);
assert_eq!(csv.rows, vec![vec!["Ada;London"]]);
let explicit = parse::table_from_bytes(body.as_bytes(), "people.csv", Some(Delimiter::Semicolon)).unwrap();
assert_eq!(explicit.rows, vec![vec!["Ada", "London"]]);
let tab = parse::table_from_bytes(b"Name,Title\tCity\nAda,Analyst\tLondon", "people.tsv", None).unwrap();
assert_eq!(tab.delimiter, Delimiter::Tab);
assert_eq!(tab.rows, vec![vec!["Ada,Analyst", "London"]]);
}
#[test]
fn text_sniff_padding_skips_and_warnings() {
let dir = scratch("sniff");
+4 -1
View File
@@ -100,7 +100,7 @@ fn outside_text(text: &str) -> Result<String> {
fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> {
use designcraft_geom::{Point, Rect, shapes};
const LIMIT: f64 = 1.0e5;
let sr = super::spread_param(p, "spread");
let sr = super::spread_param(p, "spread")?;
let at = super::point_param(p, "at")
.filter(|a| a.x.is_finite() && a.y.is_finite())
.map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT)));
@@ -169,6 +169,8 @@ pub fn specs() -> Vec<CommandSpec> {
st.doc = e.doc;
st.selection = e.selection;
st.revision += 1;
// Pending typing overrides belong to the current edit state, not a restored snapshot.
s.typing_format = None;
Ok(json!({"undone": e.label}))
}),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {
@@ -178,6 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
st.doc = e.doc;
st.selection = e.selection;
st.revision += 1;
s.typing_format = None;
Ok(json!({"redone": e.label}))
}),
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
+2 -2
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
if ts.cell.is_some() {
return Err(bad("note.new", "notes go in story text"));
}
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
let author = str_param(p, "author").unwrap_or("").to_string();
let at = ts.anchor.max(ts.focus);
s.edit(|d, sel| {
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let (text, author) = (str_param(p, "text").map(str::to_string), str_param(p, "author").map(str::to_string));
let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or_else(|| bad("note.edit", "no such story"))?;
let n = st.editorial.iter_mut().find(|n| n.id == id).ok_or_else(|| bad("note.edit", format!("no note {id}")))?;
+3 -3
View File
@@ -4,7 +4,7 @@
use designcraft_doc::{ParaFormat, SpreadRef, StoryId};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, _| {
let style = d.endnote_options.para_style.clone();
let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?;
@@ -103,7 +103,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.endnote_story == Some(ts.story) {
return Err(bad(ID, "the endnote frame lists the endnotes"));
}
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
let at = ts.anchor.max(ts.focus);
s.edit(|d, sel| {
// The endnote frame: a text frame in the margins of a new last page.
+5 -5
View File
@@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &r.bytes)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})),
@@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
match path {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("path").and_then(Value::as_str) {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
@@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::atomic::write_file(path, bytes)
}
#[cfg(target_arch = "wasm32")]
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
+61 -27
View File
@@ -25,9 +25,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string();
let bytes = to_bytes(&s.doc()?.doc);
let mut copy = (*s.doc()?.doc).clone();
super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
let bytes = to_bytes(&copy);
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
@@ -37,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
let dir = recovery_dir(s, p)?;
Ok(json!({"saved": crate::recovery::save(s, &dir)?}))
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
}),
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
let dir = recovery_dir(s, p)?;
@@ -237,24 +239,26 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
.map(str::to_string)
.or_else(|| st.path.clone())
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
{
let mut d = (*st.doc).clone();
super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path));
if d.data_merge != st.doc.data_merge {
st.doc = Arc::new(d);
st.revision = st.revision.saturating_add(1);
}
}
let bytes = to_bytes(&st.doc);
// Prepare paths for the destination without changing the live document until persistence succeeds.
let mut saved = (*st.doc).clone();
super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path));
let paths_changed = saved.data_merge != st.doc.data_merge;
let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() };
let bytes = to_bytes(&saved);
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(&path, &bytes)?;
if paths_changed {
st.doc = saved;
st.revision = st.revision.saturating_add(1);
}
st.path = Some(path.clone());
st.saved_revision = st.revision;
st.saved_doc = st.doc.clone();
#[cfg(not(target_arch = "wasm32"))]
if let Some(dir) = &s.recovery_dir {
let uid = s.doc()?.uid;
crate::recovery::discard(dir, uid);
{
let recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
}
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@@ -331,7 +335,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "spread");
let spread = super::spread_param(p, "file.place")?;
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
@@ -454,7 +458,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?;
let sr = super::spread_param(p, "spread");
let sr = super::spread_param(p, "place.drop")?;
let lid = s.doc()?.active_layer;
let frame = super::id_param(p, "frame");
let rect = super::rect_param(p, "rect");
@@ -518,7 +522,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
@@ -546,7 +550,7 @@ fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Re
if ids.is_empty() {
return Err(bad(ID, format!("page {page} has no objects")));
}
let to = super::spread_param(p, "spread");
let to = super::spread_param(p, ID)?;
let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0),
_ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0),
@@ -582,7 +586,7 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad("snippet.place", "the snippet is empty"));
}
let sr = super::spread_param(p, "spread");
let sr = super::spread_param(p, "snippet.place")?;
let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0),
@@ -643,15 +647,14 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
let (fonts, _, _) = load_document_fonts(&mut d, &path);
let uid = s.doc()?.uid;
let st = s.doc_mut()?;
let recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
let mut fresh = DocState::new(d, Some(path.clone()));
fresh.fonts = fonts;
fresh.uid = uid;
fresh.revision = st.revision + 1;
*st = fresh;
if let Some(dir) = &s.recovery_dir {
crate::recovery::discard(dir, uid);
}
fresh.revision = s.doc()?.revision + 1;
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
*s.doc_mut()? = fresh;
Ok(json!({"path": path}))
}
#[cfg(target_arch = "wasm32")]
@@ -735,6 +738,37 @@ mod layout_place_tests {
}
}
#[cfg(test)]
mod save_tests {
use serde_json::json;
use crate::Session;
/// Saving over an existing document replaces it whole and leaves nothing beside it.
#[test]
fn save_replaces_the_existing_file_without_leftovers() {
let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.designcraft");
std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap();
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap();
let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
assert!(saved.stories.values().any(|st| st.text == "Saved whole"));
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(names, ["doc.designcraft"], "no temporary file is left");
// A folder that doesn't exist: an error naming the path.
let missing = dir.join("missing").join("doc.designcraft");
let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err();
assert!(e.to_string().contains("missing"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod document_fonts_tests {
use std::path::{Path, PathBuf};
+1
View File
@@ -181,6 +181,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
over.set_json(k, v).map_err(|e| bad("find.change", e))?;
}
}
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
let targets = scope(s, p)?;
s.edit(|d, sel| {
let mut count = 0usize;
+5 -22
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide),
cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete),
cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| {
let r = guide_spread(p, "guide.deleteAll")?;
let r = super::spread_param(p, "guide.deleteAll")?;
s.edit(|d, _| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| {
let d = &s.doc()?.doc;
let sp = d.spread(guide_spread(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
@@ -71,23 +71,6 @@ fn optional_usize(p: &Value, key: &str, command: &str) -> Result<Option<usize>>
p.get(key).map(|value| checked_usize(value, key, command)).transpose()
}
/// Numeric spread shorthand or the serialized `SpreadRef` form, without lossy/defaulting casts.
fn guide_spread(p: &Value, command: &str) -> Result<SpreadRef> {
let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) };
if value.is_number() {
return Ok(SpreadRef::Doc(checked_usize(value, "spread", command)?));
}
let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or a spread reference"))?;
let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| bad(command, "`spread.kind` must be `doc` or `parent`"))?;
let index = object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?;
let index = checked_usize(index, "spread.index", command)?;
match kind {
"doc" => Ok(SpreadRef::Doc(index)),
"parent" => Ok(SpreadRef::Parent(index)),
_ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")),
}
}
fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
let Some(value) = p.get(key) else { return Ok(None) };
let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?;
@@ -113,7 +96,7 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize {
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let o = orientation(p)?;
let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?;
let r = guide_spread(p, "guide.add")?;
let r = super::spread_param(p, "guide.add")?;
let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false);
let d = &s.doc()?.doc;
let pi = match optional_usize(p, "page", "guide.add")? {
@@ -139,7 +122,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> {
let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?;
let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?;
Ok((guide_spread(p, cmd)?, pi, i))
Ok((super::spread_param(p, cmd)?, pi, i))
}
/// Commands must use the same guide/layer protection as pointer editing.
@@ -237,7 +220,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
let cg = guide_gutter(p, "columnGutter")?;
let to_page = str_param(p, "fitTo") == Some("page");
let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false);
let r = guide_spread(p, "layout.createGuides")?;
let r = super::spread_param(p, "layout.createGuides")?;
let only = optional_usize(p, "page", "layout.createGuides")?;
let page_count = {
let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
+1 -1
View File
@@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
+7 -4
View File
@@ -29,7 +29,7 @@ impl Library {
#[cfg(not(target_arch = "wasm32"))]
if let Some(p) = &self.path {
let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?;
std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?;
crate::atomic::write_file(p, &bytes)?;
}
Ok(())
}
@@ -86,10 +86,12 @@ pub fn specs() -> Vec<CommandSpec> {
let n = st.selection.items.len();
(str_param(p, "name").map(str::to_string).unwrap_or_else(|| if n > 1 { format!("{n} objects") } else { kind.clone() }), str_param(p, "description").unwrap_or("").to_string())
};
let lib = s.library.as_mut().ok_or_else(no_library)?;
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let index = lib.items.len();
lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) });
lib.save()?;
Ok(json!({"index": lib.items.len() - 1}))
s.library = Some(lib);
Ok(json!({"index": index}))
}),
cmd!(
"library.place",
@@ -107,13 +109,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| {
let lib = s.library.as_mut().ok_or_else(no_library)?;
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("library.remove", "`index` required"))? as usize;
if i >= lib.items.len() {
return Err(bad("library.remove", format!("no item {i}")));
}
lib.items.remove(i);
lib.save()?;
s.library = Some(lib);
ok()
}),
cmd!(noundo "conveyor.collect", "Collect", [], None, "{ids? (default: the selection)} — each object onto the Content Collector conveyor → {count}", has_selection, |s, p| {
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let parent = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.placeAndLink", "missing story"))?);
let rect = rect_param(p, "rect").ok_or_else(|| bad("story.placeAndLink", "missing rect"))?;
let sr = spread_param(p, "spread");
let sr = spread_param(p, "story.placeAndLink")?;
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
if d.story(parent).is_none() {
+1 -1
View File
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!("guide.liquid", "Convert to Liquid Guide", [], None, "{spread?, page, index, on?: bool (default true)}", has_doc, |s, p| {
let sr = super::spread_param(p, "spread");
let sr = super::spread_param(p, "guide.liquid")?;
let pi = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
let gi = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("guide.liquid", "`index` required"))? as usize;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(true);
+1 -1
View File
@@ -70,7 +70,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
});
}
let text = s.doc()?.selection.text.filter(|t| t.cell.is_none());
let spread = super::spread_param(p, "spread");
let spread = super::spread_param(p, "math.insert")?;
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
s.edit(|d, sel| {
if let Some(t) = text {
+1 -1
View File
@@ -20,7 +20,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "spread");
let spread = super::spread_param(p, "file.place")?;
let lid = s.doc()?.active_layer;
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
let mime = mime.to_string();
+17 -6
View File
@@ -256,12 +256,23 @@ pub(crate) fn rect_param(p: &Value, key: &str) -> Option<Rect> {
let f = |i: usize| a.get(i).and_then(Value::as_f64);
Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?))
}
/// `{"kind":"doc","index":0}`, `0` or absent (= spread 0).
pub(crate) fn spread_param(p: &Value, key: &str) -> SpreadRef {
match p.get(key) {
Some(Value::Number(n)) => SpreadRef::Doc(n.as_u64().unwrap_or(0) as usize),
Some(v) => serde_json::from_value(v.clone()).unwrap_or(SpreadRef::Doc(0)),
None => SpreadRef::Doc(0),
/// The `spread` param: a body spread index (`0`), a spread reference (`{"kind":"doc"|"parent","index":0}`)
/// or absent (= spread 0). Anything else is an error: read as spread 0, a script aiming at a parent
/// spread got its items on the first page without a word.
pub(crate) fn spread_param(p: &Value, command: &str) -> Result<SpreadRef> {
let index = |v: &Value, key: &str| {
v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))
};
let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) };
if value.is_number() {
return Ok(SpreadRef::Doc(index(value, "spread")?));
}
let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or {kind: doc|parent, index}"))?;
let i = index(object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?, "spread.index")?;
match object.get("kind").and_then(Value::as_str) {
Some("doc") => Ok(SpreadRef::Doc(i)),
Some("parent") => Ok(SpreadRef::Parent(i)),
_ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")),
}
}
+3 -3
View File
@@ -3,7 +3,7 @@
use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use super::{CommandSpec, bad, cmd, has_doc};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> {
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?;
let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() };
@@ -130,7 +130,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.setText")?;
let id = note_id(p, "footnote.setText")?;
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?;
+618 -17
View File
@@ -14,6 +14,17 @@ use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets};
use crate::{Result, Session};
/// `name` must be a swatch of the document: an unknown name would paint nothing. The error names
/// the swatch it was probably meant to be (`Paper` → `[Paper]`, a different case).
fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &str, key: &str) -> Result<()> {
if doc.swatch(name).is_some() {
return Ok(());
}
let bare = |n: &str| n.trim().trim_start_matches('[').trim_end_matches(']').to_lowercase();
let hint = doc.swatches.iter().find(|s| bare(&s.name) == bare(name)).map(|s| format!(" (did you mean `{}`?)", s.name)).unwrap_or_default();
Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name")))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -21,12 +32,20 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Frame",
[],
None,
"{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
"{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
has_doc,
frame_create
),
cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move),
cmd!(
"transform.move",
"Move",
["Object", "Transform"],
None,
"{dx, dy, copy?: bool, ids?, toSpread?} → {moved, ids: the objects moved (with copy, the copies)}",
has_selection,
transform_move
),
cmd!(
"transform.resize",
"Resize",
@@ -160,11 +179,30 @@ pub fn specs() -> Vec<CommandSpec> {
)
}
),
cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| {
cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| {
let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string();
known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?;
let tint = f64_or(p, "tint", 1.0) as f32;
set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false)
}),
cmd!(
"object.swapFillStroke",
"Swap Fill and Stroke",
[],
None,
"{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are",
has_editable_selection,
|s, p| edit_unlocked(s, p, swap_fill_stroke)
),
cmd!(
"object.defaultFillStroke",
"Default Fill and Stroke",
[],
None,
"{ids?} — no fill and a black stroke; locked objects stay as they are",
has_editable_selection,
|s, p| edit_unlocked(s, p, default_fill_stroke)
),
cmd!(
"object.stroke",
"Stroke",
@@ -173,6 +211,11 @@ pub fn specs() -> Vec<CommandSpec> {
"{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
has_selection,
|s, p| {
for key in ["swatch", "gapSwatch"] {
if let Some(name) = p.get(key).and_then(Value::as_str) {
known_swatch(&s.doc()?.doc, name, "object.stroke", key)?;
}
}
let p2 = p.clone();
set_flag(
s,
@@ -565,13 +608,39 @@ pub fn specs() -> Vec<CommandSpec> {
"Step and Repeat…",
["Edit"],
Some("Cmd+Alt+U"),
"{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents",
"{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents → {created, ids: the copies}",
has_selection,
step_and_repeat
),
cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create),
cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append),
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close),
cmd!(
"path.appendAnchor",
"Add Anchor",
[],
None,
"{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor",
has_doc,
path_append
),
cmd!(
"path.setAnchor",
"Set Anchor",
[],
None,
"{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth",
has_doc,
path_set_anchor
),
cmd!(
"path.reverse",
"Reverse Path",
["Object", "Paths"],
None,
"{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)",
has_selection,
path_reverse
),
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
cmd!(
"object.align",
@@ -803,7 +872,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?;
let shape = str_param(p, "shape").unwrap_or("rectangle");
let content = str_param(p, "content").unwrap_or("graphic");
let sr = spread_param(p, "spread");
let sr = spread_param(p, "frame.create")?;
let lid = s.doc()?.active_layer;
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
@@ -866,7 +935,7 @@ pub(crate) fn polygon_in(r: Rect, sides: u32, inset: f64) -> designcraft_geom::P
fn line_create(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?;
let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?;
let sr = spread_param(p, "spread");
let sr = spread_param(p, "line.create")?;
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
@@ -981,7 +1050,7 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
}
it.xf = Affine::translate((dx, dy)) * it.xf;
}
Ok(json!({"moved": ids.len()}))
Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
@@ -1293,6 +1362,43 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let to = str_param(p, "to").unwrap_or("front").to_string();
s.edit(|d, _| {
if matches!(to.as_str(), "forward" | "backward") {
let selected: HashSet<ItemId> = ids.iter().copied().collect();
let mut positions: Vec<_> = selected
.iter()
.filter_map(|id| {
let loc = d.find(*id)?;
match loc.path.as_slice() {
[i] => Some((loc.spread, *i)),
_ => None,
}
})
.collect();
// Move the leading selected items first, independently of selection/click order.
// Swapping only with unselected siblings leaves the other selected indices valid.
positions.sort_by_key(|(_, i)| *i);
let forward = to == "forward";
if forward {
positions.reverse();
}
for (spread, i) in positions {
let Some(sp) = d.spread_mut(spread) else { continue };
let Some(it) = sp.items.get(i) else { continue };
let layer = it.layer;
let sibling = if forward {
sp.items.iter().enumerate().skip(i.saturating_add(1)).find(|(_, it)| it.layer == layer)
} else {
sp.items.iter().enumerate().take(i).rev().find(|(_, it)| it.layer == layer)
};
if let Some((j, it)) = sibling
&& !selected.contains(&it.id)
{
// Keep every other layer's slots and relative stacking order untouched.
sp.items.swap(i, j);
}
}
return ok();
}
for id in &ids {
let Some(loc) = d.find(*id) else { continue };
if loc.path.len() != 1 {
@@ -1305,7 +1411,6 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
let j = match to.as_str() {
"front" => n - 1,
"back" => 0,
"forward" => (i + 1).min(n - 1),
_ => i.saturating_sub(1),
};
sp.items.insert(j, it);
@@ -1379,6 +1484,60 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -
})
}
/// An object a colour command may change: neither it nor its layer is locked.
fn unlocked(d: &Document, it: &Item) -> bool {
!it.locked && d.layer(it.layer).is_none_or(|l| !l.locked)
}
fn has_editable_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?;
if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) {
Ok(())
} else {
Err("the selected objects are locked".into())
}
}
/// Runs `f` on each unlocked target in one edit (one undo step).
fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, _| {
let mut changed = 0;
for id in &ids {
if d.item(*id).is_some_and(|it| unlocked(d, it))
&& let Some(it) = d.item_mut(*id)
{
f(it);
changed += 1;
}
}
Ok(json!({"changed": changed}))
})
}
/// A stroke that gets a colour needs a weight to show it.
fn visible_weight(st: &mut Stroke) {
if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 {
st.weight = Stroke::default().weight;
}
}
fn swap_fill_stroke(it: &mut Item) {
let fill = std::mem::take(&mut it.fill);
it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() };
it.stroke.swatch = fill.swatch;
it.stroke.tint = fill.tint;
visible_weight(&mut it.stroke);
}
fn default_fill_stroke(it: &mut Item) {
it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() };
it.stroke.swatch = designcraft_color::swatch::BLACK.into();
it.stroke.tint = 1.0;
visible_weight(&mut it.stroke);
}
fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result<Value> {
s.edit(|d, _| {
for i in 0..d.spreads.len() {
@@ -1567,7 +1726,7 @@ fn gap_move(s: &mut Session, p: &Value) -> Result<Value> {
.and_then(Value::as_array)
.and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)))
.ok_or_else(|| bad(ID, "missing at"))?;
let sr = spread_param(p, "spread");
let sr = spread_param(p, ID)?;
let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?;
// `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts.
let delta =
@@ -1862,6 +2021,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
for ((id, r), t) in items.iter().zip(targets) {
let delta = t - lo(r);
if let Some(it) = d.item_mut(*id) {
// Keep locked items in the spacing calculation, as align does for its reference.
if it.locked {
continue;
}
let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) };
it.xf = Affine::translate(v) * it.xf;
}
@@ -1894,7 +2057,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("path.create", "a path needs at least two anchors"));
}
let closed = bool_or(p, "closed", false);
let sr = spread_param(p, "spread");
let sr = spread_param(p, "path.create")?;
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
@@ -1910,11 +2073,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
let subpath = p.get("subpath").and_then(Value::as_u64);
s.edit(|d, _| {
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?;
let sp = match subpath {
Some(i) => {
usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?
}
None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
};
if sp.closed {
return Err(bad("path.appendAnchor", "the subpath is closed"));
}
sp.anchors.push(a.transform(inv));
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
@@ -1923,12 +2095,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "path.setAnchor";
let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?;
let pt = |k: &str| -> Option<Point> {
let a = p.get(k)?.as_array()?;
let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?);
(q.x.is_finite() && q.y.is_finite()).then_some(q)
};
let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out"));
if new_p.is_none() && new_in.is_none() && new_out.is_none() {
return Err(bad(ID, "give p, in or out"));
}
let subpath = p.get("subpath").and_then(Value::as_u64);
let which = p.get("anchor").cloned().unwrap_or(Value::Null);
s.edit(|d, _| {
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if it.locked {
return Err(bad(ID, "the path is locked"));
}
let sp = match subpath {
Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)),
None => it.path.subpaths.last_mut(),
}
.ok_or_else(|| bad(ID, "no such subpath"))?;
let n = sp.anchors.len();
let ai = match &which {
Value::String(w) if w == "first" => 0,
Value::String(w) if w == "last" => n.saturating_sub(1),
v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?,
};
let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?;
if let Some(q) = new_p {
let v = inv * q - a.p;
a.p += v;
a.h_in += v;
a.h_out += v;
}
if let Some(q) = new_in {
a.h_in = inv * q;
}
if let Some(q) = new_out {
a.h_out = inv * q;
}
if new_in.is_some() && new_out.is_some() {
let (i, o) = (a.h_in - a.p, a.h_out - a.p);
let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0;
a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner };
}
ok()
})
}
fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
if let (Some(i), Some(id)) = (only, ids.first())
&& s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none())
{
return Err(bad("path.reverse", "no such subpath"));
}
let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
s.edit(|d, _| {
let mut reversed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
if it.locked || it.is_group() {
continue;
}
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
if only.is_some_and(|o| o != i) {
continue;
}
sp.anchors.reverse();
for a in &mut sp.anchors {
std::mem::swap(&mut a.h_in, &mut a.h_out);
}
}
reversed += 1;
}
Ok(json!({"reversed": reversed}))
})
}
fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
s.edit(|d, _| {
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
for sp in &mut it.path.subpaths {
sp.closed = true;
if only.is_some_and(|i| i >= it.path.subpaths.len()) {
return Err(bad("path.close", "no such subpath"));
}
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
if only.is_none_or(|o| o == i) {
sp.closed = true;
}
}
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
@@ -2091,14 +2354,195 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
}
}
*sel = Selection::items(all.clone());
Ok(json!({"created": all.len() - ids.len()}))
let copies: Vec<u64> = all.iter().skip(ids.len()).map(|i| i.0).collect();
Ok(json!({"created": copies.len(), "ids": copies}))
})
}
#[cfg(test)]
mod arrange_tests {
use super::*;
fn fixture<const N: usize>(layers: [usize; N]) -> (Session, [ItemId; N]) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let first = s.doc().unwrap().doc.default_layer().0;
let second = s.execute("layer.new", &json!({"name": "Other layer"})).unwrap()["id"].as_u64().unwrap();
let layer_ids = [first, second];
let mut ids = [ItemId(0); N];
for (id, layer) in ids.iter_mut().zip(layers) {
// Overlap objects within each layer; separate layers spatially for hit testing.
let x = if layer == 0 { 0 } else { 200 };
*id = ItemId(s.execute("frame.create", &json!({"rect": [x, 0, x + 100, 100]})).unwrap()["id"].as_u64().unwrap());
s.execute("object.setLayer", &json!({"ids": [id.0], "layer": layer_ids[layer]})).unwrap();
}
(s, ids)
}
fn stack(s: &Session) -> Vec<ItemId> {
s.doc().unwrap().doc.spreads[0].items.iter().map(|it| it.id).collect()
}
fn snapshot(s: &Session) -> Value {
let st = s.doc().unwrap();
json!({"document": &*st.doc, "selection": &st.selection})
}
fn assert_items_unchanged(before: &Document, after: &Document) {
assert_eq!(serde_json::to_value(&before.layers).unwrap(), serde_json::to_value(&after.layers).unwrap());
for it in &before.spreads[0].items {
assert_eq!(serde_json::to_value(it).unwrap(), serde_json::to_value(after.item(it.id).unwrap()).unwrap());
}
}
#[test]
fn arrange_steps_past_other_layers_change_hit_order_and_undo() {
for direction in ["forward", "backward"] {
let (mut s, [a, x, y, b]) = fixture([0, 1, 1, 0]);
let selected = if direction == "forward" { a } else { b };
s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap();
let before = snapshot(&s);
let doc = s.doc().unwrap().doc.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let hit = |s: &Session| s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0);
assert_eq!(hit(&s), Some(b));
s.execute("object.arrange", &json!({"to": direction})).unwrap();
assert_eq!(stack(&s), [b, x, y, a], "{direction}");
assert_eq!(hit(&s), Some(a), "the visible topmost object must change");
assert_items_unchanged(&doc, &s.doc().unwrap().doc);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
assert_eq!(s.doc().unwrap().selection.items, [selected]);
let after = snapshot(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(snapshot(&s), before);
assert_eq!(hit(&s), Some(b));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(snapshot(&s), after);
assert_eq!(hit(&s), Some(a));
}
}
#[test]
fn arrange_multiple_selection_preserves_order_independent_of_input_order() {
// Layer 0: a, b, c, d. Layer 1: x, y. Arrays name the original stack indices.
let cases = [
("forward", [0, 2], [4, 1, 0, 3, 2, 5]), // a,b cross c as a block
("backward", [2, 4], [2, 1, 4, 3, 0, 5]), // b,c cross a as a block
("forward", [0, 4], [2, 1, 0, 3, 5, 4]), // separate selected blocks
("backward", [2, 5], [2, 1, 0, 3, 5, 4]),
("forward", [4, 5], [0, 1, 2, 3, 4, 5]), // front block is already at its endpoint
("backward", [0, 2], [0, 1, 2, 3, 4, 5]), // back block is already at its endpoint
];
for (direction, selected, expected) in cases {
for reverse in [false, true] {
let (mut s, ids) = fixture([0, 1, 0, 1, 0, 0]);
let doc = s.doc().unwrap().doc.clone();
let mut selected = selected.map(|i| ids[i].0).to_vec();
if reverse {
selected.reverse();
}
// Explicit command targets may contain duplicates: an item still moves once.
selected.push(selected[0]);
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), expected.map(|i| ids[i]), "{direction}, reverse={reverse}");
assert_items_unchanged(&doc, &s.doc().unwrap().doc);
}
}
}
#[test]
fn arrange_same_layer_steps_stop_at_layer_endpoints() {
let cases = [
("forward", vec![1], [0, 2, 1, 3, 4]),
("backward", vec![3], [0, 1, 3, 2, 4]),
("forward", vec![3], [0, 1, 2, 3, 4]),
("backward", vec![1], [0, 1, 2, 3, 4]),
("forward", vec![3, 1, 2], [0, 1, 2, 3, 4]),
("backward", vec![3, 1, 2], [0, 1, 2, 3, 4]),
];
for (direction, selected, expected) in cases {
let (mut s, ids) = fixture([1, 0, 0, 0, 1]);
let selected: Vec<_> = selected.into_iter().map(|i| ids[i].0).collect();
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), expected.map(|i| ids[i]));
}
for direction in ["forward", "backward"] {
let (mut s, [x, a, y]) = fixture([1, 0, 1]);
s.execute("object.arrange", &json!({"to": direction, "ids": [a.0]})).unwrap();
assert_eq!(stack(&s), [x, a, y], "only object on its layer");
}
}
#[test]
fn arrange_selection_on_two_layers_moves_each_layer_independently() {
for direction in ["forward", "backward"] {
let (mut s, [a, x, b, y]) = fixture([0, 1, 0, 1]);
let selected = if direction == "forward" { [x.0, a.0] } else { [b.0, y.0] };
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), [b, y, a, x]);
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a));
assert_eq!(doc.hit_item(0, Point::new(250.0, 50.0), 0.0), Some(x));
}
}
#[test]
fn arrange_menu_steps_use_same_layer_order() {
for command in ["object.bringForward", "object.sendBackward"] {
let (mut s, [a, x, b]) = fixture([0, 1, 0]);
let selected = if command == "object.bringForward" { a } else { b };
s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap();
s.execute(command, &json!({})).unwrap();
assert_eq!(stack(&s), [b, x, a]);
assert_eq!(s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a));
}
}
}
#[cfg(test)]
mod step_repeat_tests {
use super::*;
#[test]
fn swap_and_default_fill_stroke_are_one_undo_step_per_object() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = |v: Value| ItemId(v["id"].as_u64().unwrap());
let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap());
let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap());
let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap());
s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap();
s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap();
s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap();
s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap();
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap();
let colors = |s: &Session, i: ItemId| {
let it = s.doc().unwrap().doc.item(i).unwrap().clone();
(it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint)
};
let before = [colors(&s, a), colors(&s, b), colors(&s, locked)];
assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2);
assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5));
assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0));
assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both");
s.execute("object.defaultFillStroke", &json!({})).unwrap();
for i in [a, b] {
assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0));
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before);
s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap();
assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change");
}
#[test]
fn step_repeat_validates_counts_and_offsets() {
assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize);
@@ -2163,7 +2607,7 @@ mod scale_tests {
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
s.execute("object.stroke", &json!({"weight": 4, "swatch": "Black"})).unwrap();
s.execute("object.stroke", &json!({"weight": 4, "swatch": "[Black]"})).unwrap();
let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight;
let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds();
s.execute("transform.scale", &json!({"sx": 2.0})).unwrap();
@@ -2578,6 +3022,18 @@ mod line_end_tests {
s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap();
assert_eq!(story(&s).text, "One\nTwo\nThree\nFour");
assert_eq!(story(&s).paras.len(), 4);
// Editing a range or the whole story (scripts written for InDesign end paragraphs with \r).
s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5));
s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3));
// Notes, made and edited.
s.execute("text.select", &json!({"story": sid.0, "anchor": 1})).unwrap();
let n = s.execute("note.new", &json!({"text": "One\rTwo"})).unwrap();
let note = |s: &mut Session| s.execute("note.list", &json!({})).unwrap()[0]["text"].clone();
assert_eq!(note(&mut s), "One\nTwo");
s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap();
assert_eq!(note(&mut s), "Three\nFour");
}
}
@@ -2726,3 +3182,148 @@ mod blend_mode_tests {
assert_eq!(it.opacity, 1.0);
}
}
#[cfg(test)]
mod copy_ids_tests {
use serde_json::json;
#[test]
fn copies_report_their_ids() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let ids = |r: &serde_json::Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>();
let selected = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>();
// Moving reports what moved; copying reports the copies, which are selected.
assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a]})).unwrap()), [a]);
let r = s.execute("transform.move", &json!({"dx": 100, "dy": 0, "copy": true, "ids": [a]})).unwrap();
let copy = ids(&r);
assert!(copy.len() == 1 && copy[0] != a && s.doc().unwrap().doc.item(designcraft_doc::ItemId(copy[0])).is_some(), "{r}");
assert_eq!(selected(&s), copy);
// Edit › Duplicate.
let r = s.execute("edit.duplicate", &json!({})).unwrap();
let dup = ids(&r);
assert!(dup.len() == 1 && dup[0] != copy[0], "{r}");
assert_eq!(selected(&s), dup);
// Step and Repeat: every copy.
let r = s.execute("edit.stepAndRepeat", &json!({"count": 3, "dx": 0, "dy": 60, "ids": [a]})).unwrap();
assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}");
assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some()));
}
}
#[cfg(test)]
mod swatch_name_tests {
use serde_json::json;
#[test]
fn fills_and_strokes_must_name_a_swatch_and_change_nothing_otherwise() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone();
for (cmd, params, hint) in [
("object.fill", json!({"swatch": "Paper", "ids": [id]}), Some("[Paper]")),
("object.fill", json!({"swatch": "[black]", "ids": [id]}), Some("[Black]")),
("object.fill", json!({"swatch": "Missing Ink", "ids": [id]}), None),
("object.stroke", json!({"swatch": "#ff0000", "weight": 4, "ids": [id]}), None),
("object.stroke", json!({"gapSwatch": "paper", "ids": [id]}), Some("[Paper]")),
] {
let before = s.doc().unwrap().doc.clone();
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains("no swatch named") && e.contains("swatch.create"), "{cmd}: {e}");
if let Some(h) = hint {
assert!(e.contains(&format!("did you mean `{h}`")), "{cmd}: {e}");
}
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{cmd}: nothing changed");
}
// Real names, [None] included, still work.
for sw in ["[Paper]", "[None]", "C=100 M=0 Y=0 K=0"] {
s.execute("object.fill", &json!({"swatch": sw, "ids": [id]})).unwrap();
s.execute("object.stroke", &json!({"swatch": sw, "ids": [id]})).unwrap();
}
}
}
#[cfg(test)]
mod path_edit_tests {
use designcraft_doc::ItemId;
use designcraft_geom::Point;
use serde_json::json;
fn session_with_path() -> (crate::Session, u64) {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s
.execute(
"path.create",
&json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
(s, id)
}
fn anchors(s: &crate::Session, id: u64) -> Vec<designcraft_geom::Anchor> {
let d = &s.doc().unwrap().doc;
let it = d.item(ItemId(id)).unwrap();
let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf;
it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect()
}
#[test]
fn reverse_path_swaps_ends_and_direction_lines() {
let (mut s, id) = session_with_path();
s.execute("path.reverse", &json!({"ids": [id]})).unwrap();
let a = anchors(&s, id);
assert_eq!(a.iter().map(|a| a.p).collect::<Vec<_>>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]);
assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0)));
assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err());
}
#[test]
fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() {
let (mut s, id) = session_with_path();
s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap();
assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0));
assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err());
// Direction lines on the last anchor: collinear and opposite make it smooth.
s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap();
let last = *anchors(&s, id).last().unwrap();
assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth));
s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap();
assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0));
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err());
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set");
// Closing one subpath; then it takes no more anchors.
s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap();
assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err());
assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err());
}
}
#[cfg(test)]
mod spread_target_tests {
use super::*;
#[test]
fn creating_on_a_parent_spread_and_rejecting_unknown_spreads() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let parent = json!({"kind": "parent", "index": 0});
let r = s.execute("frame.create", &json!({"spread": parent, "rect": [0, 0, 100, 100]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().doc.find(id).unwrap().spread, SpreadRef::Parent(0));
s.execute("line.create", &json!({"spread": parent, "a": [0, 0], "b": [50, 50]})).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[0].items.len(), 2);
// Each of these used to land on the first body page.
for bad in
[json!("A-Parent"), json!("0"), json!(-1), json!(1.5), json!({"kind": "master", "index": 0}), json!({"kind": "parent"}), json!([0])]
{
assert!(s.execute("frame.create", &json!({"spread": bad, "rect": [0, 0, 100, 100]})).is_err(), "frame.create spread {bad}");
assert!(s.execute("line.create", &json!({"spread": bad, "a": [0, 0], "b": [50, 50]})).is_err(), "line.create spread {bad}");
}
assert!(s.doc().unwrap().doc.spreads[0].items.is_empty());
}
}
+113 -5
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}",
"{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path",
has_doc,
path_options
),
@@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result<Option<PathAlign>> {
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?;
let text = super::str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
let mut pt = PathType {
start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0),
flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false),
@@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
let p = p.clone();
s.edit(|d, sel| {
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length();
let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) };
let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) };
if delete {
@@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
sel.text = None;
return Ok(json!({"deleted": true}));
}
if let Some(v) = p.get("start").and_then(Value::as_f64) {
pt.start = v.max(0.0);
if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) {
pt.start = v.max(0.0).min(len);
}
match p.get("end") {
Some(Value::Null) => pt.end = None,
Some(v) => {
let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?;
pt.end = (e < len - 0.01).then_some(e.max(0.0));
}
None => {}
}
// The brackets can't cross: the one being moved stops at the other.
if let Some(e) = pt.end
&& pt.start > e
{
if p.get("end").is_some() {
pt.end = Some(pt.start);
} else {
pt.start = e;
}
}
if let Some(v) = p.get("flip").and_then(Value::as_bool) {
pt.flip = v;
@@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = align {
pt.align = a;
}
Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align}))
Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align}))
})
}
@@ -160,4 +179,93 @@ mod tests {
let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap();
assert!(pdf.len() > 1000);
}
#[test]
fn the_brackets_set_where_the_text_starts_and_ends() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some();
let undo = s.doc().unwrap().history.undo.len();
assert!(!overset(&s));
// The end bracket close to the start: the text no longer fits, and is overset.
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0)));
assert!(overset(&s));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// Brackets can't cross: the start stops at the end, the end at the start.
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap();
assert_eq!(r["start"].as_f64(), Some(70.0));
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0)));
// Past the path, or null: the path's end again; the start stays on the path.
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap();
assert!(r["end"].is_null());
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap();
assert_eq!(r["end"].as_f64(), Some(200.0));
let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true));
assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err());
// Saved with the document.
let d = s.doc().unwrap().doc.clone();
let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap();
let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone();
assert_eq!(path(&back), path(&d));
}
#[test]
fn the_type_tool_clicks_and_selects_along_a_curved_path() {
for flip in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// An arch: up from (100, 400) over (300, 200) and down to (500, 400).
let id = s
.execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]}))
.unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
// Where each glyph sits on the path, from the composed line.
let d = s.doc().unwrap().doc.clone();
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
let ft = &cs.frames[0];
let map = designcraft_compose::PathTextMap::new(it, ft).unwrap();
let l = &ft.lines[0];
let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap();
let on_path = |byte: usize| {
let g = glyph(byte);
// A little outside the path, as a click near the letter would be.
map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0))
};
// A click by the first C (byte 10), on the curve: the caret goes there.
let p = on_path(10);
let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap();
assert_eq!(r["pos"], 10, "flip={flip}: {r}");
// Dragging to the first E (byte 20) selects C to D.
let q = on_path(20);
s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!(t.range(), 10..20, "flip={flip}");
// A double-click on a word (its first click places the caret) selects it.
let w = on_path(16);
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap();
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}");
// A click on a letter, above the path, hits the frame (the type tool's hit test).
let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7));
let sp = s.doc().unwrap().doc.clone();
assert_eq!(
sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0),
Some(designcraft_doc::ItemId(id)),
"flip={flip}"
);
// The map runs both ways along the path.
let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline)));
assert!((back.x - 123.0).abs() < 0.01, "{back:?}");
}
}
}
+47 -40
View File
@@ -5,6 +5,7 @@
use designcraft_compose::ComposedStory;
use designcraft_doc::{Content, Document, Intent, Item, SpreadRef};
use designcraft_fonts::FaceRef;
use designcraft_geom::Affine;
use serde::Serialize;
use serde_json::{Value, json};
@@ -29,10 +30,12 @@ pub fn specs() -> Vec<CommandSpec> {
"{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)]
}
fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> {
fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usize> {
let SpreadRef::Doc(si) = sr else { return None };
let sp = d.spreads.get(si)?;
Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0))
// Item bounds include its own transform; ancestors bring the frame into spread space.
let bounds = parent.transform_rect_bbox(it.bounds());
Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0))
}
/// Characters a missing-glyph report names before "and N more".
@@ -85,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
missing_glyphs(&cs, &story.text, 0, &mut missing);
if cs.is_overset() {
let last = story.frames.last().copied();
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it));
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY));
let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count();
out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page });
}
@@ -161,48 +164,52 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
page: None,
});
}
// Items.
// Carry the ancestor transform in the same order as rendering, retaining pre-order.
for (si, sp) in d.spreads.iter().enumerate() {
for top in &sp.items {
top.walk(&mut |it| {
let page = page_of(d, SpreadRef::Doc(si), it);
match &it.content {
Content::Graphic(g) => match d.assets.get(&g.asset) {
None => {
out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page })
}
Some(a) if a.data.is_empty() => out.push(Issue {
severity: "error",
kind: "missingLink",
message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
item: Some(it.id.0),
page,
}),
Some(a) => {
// Effective ppi: pixels per inch at the placed size. Placed PDF and
// SVG pages export as vectors; their `pixels` is only the preview's.
let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data);
if let Some((pw, _)) = a.pixels.filter(|_| !vector) {
let placed_w = (g.xf * it.xf).as_coeffs()[0].hypot((g.xf * it.xf).as_coeffs()[1]) * g.size.0;
let ppi = pw as f64 / (placed_w / 72.0).max(1e-6);
if d.settings.intent == Intent::Print && ppi < min_ppi {
out.push(Issue {
severity: "warning",
kind: "lowResolution",
message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name),
item: Some(it.id.0),
page,
});
}
let mut pending: Vec<_> = sp.items.iter().rev().map(|it| (it.as_ref(), Affine::IDENTITY)).collect();
while let Some((it, parent)) = pending.pop() {
let xf = parent * it.xf;
pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf)));
let page = page_of(d, SpreadRef::Doc(si), it, parent);
match &it.content {
Content::Graphic(g) => match d.assets.get(&g.asset) {
None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }),
Some(a) if a.data.is_empty() => out.push(Issue {
severity: "error",
kind: "missingLink",
message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
item: Some(it.id.0),
page,
}),
Some(a) => {
// Placed PDF and SVG pages export as vectors; `pixels` is only the preview's.
let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data);
if let Some((pw, ph)) = a.pixels.filter(|_| !vector) {
// Measure each image axis in points, not its rotated bounding box or
// cropped frame. Either axis can fall below the minimum resolution.
let [xx, yx, xy, yy, _, _] = (xf * g.xf).as_coeffs();
let placed_w = xx.hypot(yx) * g.size.0;
let placed_h = xy.hypot(yy) * g.size.1;
let ppi_x = pw as f64 / (placed_w / 72.0).max(1e-6);
let ppi_y = ph as f64 / (placed_h / 72.0).max(1e-6);
let ppi = ppi_x.min(ppi_y);
if d.settings.intent == Intent::Print && ppi < min_ppi {
out.push(Issue {
severity: "warning",
kind: "lowResolution",
message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name),
item: Some(it.id.0),
page,
});
}
}
},
Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => {
out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page });
}
_ => {}
},
Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => {
out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page });
}
});
_ => {}
}
}
}
// RGB swatches in use in a print document.
+1 -1
View File
@@ -58,7 +58,7 @@ fn qr_code(s: &mut Session, p: &Value) -> Result<Value> {
let text = encode(p).map_err(|e| bad(ID, e))?;
let color = str_param(p, "color").unwrap_or(designcraft_color::swatch::BLACK).to_string();
let rect = rect_param(p, "rect");
let sr = spread_param(p, "spread");
let sr = spread_param(p, ID)?;
let st = s.doc()?;
let lid = st.active_layer;
let sel = st.selection.items.clone();
+436 -43
View File
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Character Style…",
[],
None,
"{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)",
"{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)",
has_doc,
create_char
),
@@ -181,9 +181,18 @@ pub fn specs() -> Vec<CommandSpec> {
[],
None,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
|s| super::has_text(s).or_else(|_| super::has_selection(s)),
has_text_or_selection,
apply_color
),
cmd!(
"color.sample",
"Sample Color (Eyedropper)",
[],
None,
"{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects",
has_text_or_selection,
sample_color
),
cmd!(
"swatch.addToSwatches",
"Add to Swatches",
@@ -444,6 +453,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
chars = full;
}
}
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
let based_on = str_param(p, "basedOn").map(str::to_string);
let next = str_param(p, "nextStyle").map(str::to_string);
s.edit(|d, _| {
@@ -470,6 +480,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
let rename = str_param(p, "rename").map(str::to_string);
let based = p.get("basedOn").cloned();
s.edit(|d, _| {
@@ -620,7 +631,26 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
// Validate caller-supplied fills; captured formatting may retain a deleted swatch name.
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?;
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
&& let Some(target) = super::text::format_targets_pub(s).first()
{
let st = s.doc()?;
// Match paragraph fromSelection's first-character/caret policy, but keep paragraph
// attributes inherited instead of freezing the fully resolved character properties.
let format = super::text::effective_char_format(s, target)?;
let mut captured = st.doc.styles.char_style_attrs(&format.style);
captured.merge(&format.over);
// Replay the explicit fields so null clears a captured value rather than looking absent.
if let Some(overrides) = p.get("chars").and_then(Value::as_object) {
for (key, value) in overrides {
captured.set_json(key, value).map_err(|e| bad("style", e))?;
}
}
chars = captured;
}
// Based on [None] is based on nothing.
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
s.edit(|d, _| {
@@ -641,6 +671,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("style.character.edit", "[None] can't be edited"));
}
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?;
// `null` unsets an attribute (the style no longer sets it).
let unset: Vec<String> = p
.get("chars")
@@ -840,7 +871,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = designcraft_color::ase::write(&chosen);
match str_param(p, "path") {
Some(path) => {
std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
#[cfg(not(target_arch = "wasm32"))]
let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes);
#[cfg(target_arch = "wasm32")]
let written = std::fs::write(path, &bytes);
written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes)})),
@@ -947,42 +982,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?;
let stroke = str_param(p, "target") == Some("stroke");
let text = p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some();
let named = p.get("ids").is_some() || p.get("id").is_some();
let text = !named
&& (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some());
let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) };
if text && texts.is_empty() {
return Err(bad("object.color", "select the text to colour"));
}
// A caret: the colour is for the next typing (once the swatch exists).
let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());
let r = s.edit(|d, _| {
// Reuse a swatch holding exactly this colour, else add an unnamed one.
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.map(|w| w.name.clone());
let name = match existing {
Some(n) => n,
None => {
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color));
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process },
locked: false,
named: false,
hidden: true,
});
name
}
};
for id in &ids {
if let Some(it) = d.item_mut(*id) {
if stroke {
it.stroke.swatch = name.clone();
it.stroke.tint = 1.0;
} else {
it.fill.swatch = name.clone();
it.fill.tint = 1.0;
}
}
}
let name = color_swatch(d, color);
color_items(d, &ids, &name, stroke);
if !caret {
let a = if stroke {
CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() }
@@ -1000,6 +1011,119 @@ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches
/// panel until Add to Swatches).
fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String {
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.map(|w| w.name.clone());
if let Some(n) = existing {
return n;
}
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color));
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process },
locked: false,
named: false,
hidden: true,
});
name
}
fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) {
for id in ids {
if let Some(it) = d.item_mut(*id) {
if stroke {
it.stroke.swatch = name.to_string();
it.stroke.tint = 1.0;
} else {
it.fill.swatch = name.to_string();
it.fill.tint = 1.0;
}
}
}
}
fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) }
}
/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour,
/// as an unnamed colour, fills the selected text (characters), or the selected objects.
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "color.sample";
let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?;
let sr = super::spread_param(p, "spread")?;
let st = s.doc()?;
let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?;
let hex = color.to_hex();
let mut params = p.clone();
params["color"] = json!(hex);
let mut result = apply_color(s, &params)?;
result["color"] = json!(hex);
result["from"] = json!(from.0);
Ok(result)
}
/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose
/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour);
/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS).
pub(crate) fn image_color_at(
d: &designcraft_doc::Document,
sr: designcraft_doc::SpreadRef,
at: designcraft_geom::Point,
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
let sp = d.spread(sr)?;
let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect();
items.sort_by_key(|i| d.layer_rank(i.layer));
items
.into_iter()
.rev()
.filter(|it| d.layer(it.layer).is_none_or(|l| l.visible))
.find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0))
}
fn sample_item(
d: &designcraft_doc::Document,
it: &designcraft_doc::Item,
outer: designcraft_geom::Affine,
at: designcraft_geom::Point,
depth: usize,
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
use designcraft_doc::Content;
if it.hidden || depth > 64 {
return None;
}
let xf = outer * it.xf;
match &it.content {
Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)),
Content::Graphic(g) => {
let inner = xf.inverse() * at;
if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) {
return None;
}
let gp = g.xf.inverse() * inner;
let (w, h) = g.size;
// Written so that NaN (a degenerate transform) is outside too.
if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) {
return None;
}
let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?;
let img = designcraft_images::decode_rgba(&asset.data)?;
let (pw, ph) = img.dimensions();
let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?);
let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?);
let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0;
(a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b)))
}
_ => None,
}
}
fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let name = match str_param(p, "swatch") {
@@ -1011,7 +1135,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let rename = str_param(p, "name").map(str::to_string);
s.edit(|d, _| {
let result = s.edit(|d, _| {
let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?;
if w.locked {
return Err(bad("swatch.addToSwatches", "special swatches are already listed"));
@@ -1040,18 +1164,26 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
}
for st in d.stories.values_mut() {
let mut copy = (**st).clone();
if rename_in_story(&mut copy, &name, &new) {
if rename_in_story(&mut copy, &name, &new, 0)? {
*st = std::sync::Arc::new(copy);
}
}
Ok(json!({"name": new}))
})
})?;
if let Some(new) = result.get("name").and_then(Value::as_str) {
super::text::rename_typing_swatch(s, &name, new);
}
Ok(result)
}
/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading,
/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story
/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed.
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str) -> bool {
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result<bool> {
// Refuse the whole edit instead of leaving deeper references pointing at a removed name.
if depth > 32 {
return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels"));
}
fn fix(s: &mut String, from: &str, to: &str) -> bool {
let hit = s == from;
if hit {
@@ -1097,17 +1229,17 @@ fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str) -> boo
for k in &mut c.strokes {
f(&mut k.color);
}
nested |= rename_in_story(&mut c.text, from, to);
nested |= rename_in_story(&mut c.text, from, to, depth + 1)?;
}
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to);
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?;
}
let changed = n > 0 || nested;
if changed {
st.rev = st.rev.wrapping_add(1);
}
changed
Ok(changed)
}
/// Point fills and strokes of an item (and its children) at a renamed swatch.
@@ -1129,6 +1261,154 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
mod color_tests {
use super::*;
fn fill_session() -> Session {
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap();
session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap();
session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap();
session.execute("style.character.create", &json!({"name": "Existing"})).unwrap();
session.execute("type.char", &json!({"size": 18})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
session
}
fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) {
let before = session.doc().unwrap().clone();
let journal = session.journal.clone();
let fill = params
.pointer("/chars/fill")
.or_else(|| params.pointer("/attrs/fill"))
.or_else(|| params.get("fill"))
.and_then(Value::as_str)
.unwrap_or("Missing Ink");
let result = session.execute(command, &params);
match result {
Err(crate::EngineError::BadParams { cmd, msg }) => {
assert_eq!(cmd, command);
assert!(msg.contains(fill), "{msg}");
assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}");
}
other => panic!("expected BadParams for {command}, got {other:?}"),
}
let after = session.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc));
assert_eq!(before.selection, after.selection);
assert_eq!(before.revision, after.revision);
assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history));
assert_eq!(journal, session.journal);
}
#[test]
fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() {
for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] {
for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] {
let mut session = fill_session();
assert_fill_rejected(&mut session, "type.char", params);
}
}
}
#[test]
fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() {
for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] {
for fill in ["Missing Ink", "#ff0000"] {
let mut session = fill_session();
session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap();
session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap();
assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}}));
}
}
}
#[test]
fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() {
let mut session = fill_session();
session
.edit(|document, selection| {
let selected = selection.text.unwrap();
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
Ok(())
})
.unwrap();
assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true}));
}
#[test]
fn char_fill_accepts_valid_override_when_selection_has_unknown_name() {
let mut session = fill_session();
session
.edit(|document, selection| {
let selected = selection.text.unwrap();
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
Ok(())
})
.unwrap();
session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap();
assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]"));
}
#[test]
fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() {
for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] {
for fill in ["Missing Ink", "#ff0000"] {
let mut session = fill_session();
assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}}));
}
}
}
#[test]
fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() {
for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] {
let mut session = fill_session();
if !fill.starts_with('[') {
let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap();
assert_eq!(created["name"], fill);
}
for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] {
session.execute("type.char", &params).unwrap();
assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill);
}
for kind in ["paragraph", "character"] {
session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap();
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap();
let document = &session.doc().unwrap().doc;
for name in ["Colored", "Existing"] {
let attrs = if kind == "paragraph" {
&document.styles.para(name).unwrap().chars
} else {
&document.styles.char_style(name).unwrap().chars
};
assert_eq!(attrs.fill.as_deref(), Some(fill));
}
}
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1);
let selected = session.doc().unwrap().selection.text.unwrap();
let story = session.doc().unwrap().doc.story(selected.story).unwrap();
assert_eq!(story.text, "Bye");
assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill));
}
}
#[test]
fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() {
for chars in [json!({"size": 18}), json!({"fill": null})] {
let mut session = fill_session();
session.execute("type.char", &json!({"attrs": chars})).unwrap();
for kind in ["paragraph", "character"] {
session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap();
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap();
}
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1);
}
}
#[test]
fn percent_components_at_most_one_stay_percent() {
use designcraft_color::Color;
@@ -1455,3 +1735,116 @@ mod cjk_tests {
assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err());
}
}
#[cfg(test)]
mod sample_color_tests {
use designcraft_color::{Color, SwatchValue};
use designcraft_doc::ItemId;
use serde_json::json;
use crate::Session;
/// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt.
fn session_with_image() -> (Session, u64) {
let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let b64 = super::super::file::base64_encode(&png);
let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap();
(s, r["id"].as_u64().unwrap())
}
fn swatch_color(s: &Session, name: &str) -> Color {
match &s.doc().unwrap().doc.swatch(name).unwrap().value {
SwatchValue::Color { color, .. } => *color,
other => panic!("{other:?}"),
}
}
#[test]
fn the_eyedropper_colours_selected_text_with_the_image_under_it() {
let (mut s, image) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap();
let fid = ItemId(f["id"].as_u64().unwrap());
let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap();
assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let name = r["swatch"].as_str().unwrap().to_string();
assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255));
assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour");
let st = s.doc().unwrap().doc.story(story).unwrap().clone();
assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str()));
assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour");
// The frame itself is not coloured.
assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none());
}
#[test]
fn the_eyedropper_fills_selected_objects_and_samples_locked_images() {
let (mut s, _) = session_with_image();
let lid = s.doc().unwrap().doc.layers[0].id.0;
s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap();
s.execute("layer.new", &json!({"name": "TEXT"})).unwrap();
let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap();
assert_eq!(r["color"], "#ff0000", "{r}");
let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone();
assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0));
// Sampling the same colour again reuses its swatch.
let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap();
assert_eq!(again["swatch"], r["swatch"]);
}
#[test]
fn nothing_to_sample_is_refused_without_an_edit() {
let (mut s, _) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap();
let fid = ItemId(f["id"].as_u64().unwrap());
let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap();
let sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]);
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing");
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string();
assert!(none.contains("no placed image"), "{none}");
// Just past the image's right edge.
assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn sampling_and_explicit_colour_use_the_same_text_application_path() {
for command in ["color.sample", "object.color"] {
let (mut s, _) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap();
let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}");
s.execute("text.insert", &json!({"text": "Hi"})).unwrap();
let text = s.doc().unwrap().doc.story(story).unwrap();
assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}");
// Explicit ids override text targeting, including with a text selection active.
let fid = ItemId(f["id"].as_u64().unwrap());
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap());
let text = s.doc().unwrap().doc.story(story).unwrap();
assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}");
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap();
let text = s.doc().unwrap().doc.story(story).unwrap();
assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}");
}
}
}
#[cfg(test)]
#[path = "text_color_tests.rs"]
mod text_color_tests;
+4 -4
View File
@@ -178,7 +178,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Split Cell Horizontally",
["Table"],
None,
"{} — the target cell becomes two, one above the other",
"{} — each target cell becomes two, one above the other",
in_table,
|s, p| split(s, p, true)
),
@@ -187,7 +187,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Split Cell Vertically",
["Table"],
None,
"{} — the target cell becomes two, side by side",
"{} — each target cell becomes two, side by side",
in_table,
|s, p| split(s, p, false)
),
@@ -589,7 +589,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let g = target(s, p, "table.split")?;
edit_table(s, &g, "table.split", |t| {
t.split_cell(g.range.r0, g.range.c0, horizontal);
t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?;
Ok(json!({"rows": t.nrows(), "cols": t.ncols()}))
})
}
@@ -645,7 +645,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))),
None => None,
};
let text = str_param(p, "text").map(str::to_string);
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = p.get("stroke").cloned();
edit_table(s, &g, "table.setCell", |t| {
let owners = t.owners();
+289 -12
View File
@@ -34,15 +34,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
),
cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point", has_doc, |s, p| {
select_around(s, p, "text.selectSentence", sentence_bounds)
}),
cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point, without its paragraph return", has_doc, |s, p| {
select_around(s, p, "text.selectParagraph", para_bounds)
}),
cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included", has_doc, |s, p| {
select_around(s, p, "text.selectStory", |t, _| (0, t.len()))
}),
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
s.typing_format = None;
let st = s.doc_mut()?;
let text = &st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.text;
let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize);
let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a));
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None });
st.revision += 1;
s.typing_format = None;
ok()
}),
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
@@ -80,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, sel| {
d.set_story_text(sid, &text)?;
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
@@ -101,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
@@ -174,6 +183,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_frames,
|s, p| link_style(s, p, false)
),
cmd!(
"type.swapFillStroke",
"Swap Text Fill and Stroke",
[],
None,
"{} — swap each selected character's resolved fill/stroke and tints; at a caret, swap the next typing's colors",
has_text_or_frames,
swap_fill_stroke
),
cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
s,
@@ -273,7 +291,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_frames,
break_link
),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs),
]
}
@@ -310,6 +328,8 @@ fn hit_byte_with(
let inner = xf.inverse() * pt;
let cs = s.cache.get(&st.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
// Map path-text hits along the curve before choosing the character unit.
let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner));
if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) {
return Some((sid, b, Some(CellAddr { table, row, col })));
}
@@ -483,8 +503,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let tracking = s.doc()?.doc.settings.track_changes;
let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone());
let typing = typing_format(s).cloned();
s.typing_format = None;
s.edit(|d, sel| {
let result = s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
@@ -562,7 +581,11 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let pos = r.start + text.len() - trail;
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
Ok(json!({"pos": pos}))
})
});
if result.is_ok() {
s.typing_format = None;
}
result
}
fn smart_quotes(s: &Session, text: &str) -> String {
@@ -739,6 +762,62 @@ pub fn prev_word(s: &str, i: usize) -> usize {
}
0
}
/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret
/// placement, a press inside the current selection is no start of a text drag here: it is the
/// third or later click of a row, which lands in the word or sentence just selected.
fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result<Value> {
let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?;
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?;
let st = s.doc_mut()?;
let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default();
let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b));
st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell });
st.revision += 1;
Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus}))
}
/// The paragraph around byte `i`, without its `\n`.
fn para_bounds(s: &str, i: usize) -> (usize, usize) {
let i = i.min(s.len());
let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1);
let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k);
(a, b)
}
/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and
/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full
/// stop, and takes the spaces after it, as deleting a sentence should leave none behind.
fn sentence_bounds(s: &str, i: usize) -> (usize, usize) {
let (pa, pb) = para_bounds(s, i);
let para = s.get(pa..pb).unwrap_or("");
let mut start = 0;
let mut it = para.char_indices().peekable();
while let Some((_, c)) = it.next() {
let cjk = matches!(c, '。' | '!' | '?');
if !cjk && !matches!(c, '.' | '!' | '?' | '…') {
continue;
}
while it.peek().is_some_and(|&(_, c)| {
matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')')
}) {
it.next();
}
if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) {
continue;
}
while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) {
it.next();
}
let end = it.peek().map_or(para.len(), |&(k, _)| k);
if pa + end > i || end == para.len() {
return (pa + start, pa + end);
}
start = end;
}
(pa + start, pb)
}
/// Type ▸ Story Direction.
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
@@ -944,6 +1023,18 @@ fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
format_chars(s, &Value::Object(body))
}
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
if let Some(fill) = &attrs.fill
&& document.swatch(fill).is_none()
{
return Err(bad(
command_id,
format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"),
));
}
Ok(())
}
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
let mut a = CharAttrs::default();
if let Some(o) = attrs.as_object() {
@@ -951,6 +1042,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
a.set_json(k, v).map_err(|e| bad("type.char", e))?;
}
}
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let targets = format_targets(s);
// A caret in text: the format is for the next typing.
@@ -1100,6 +1192,17 @@ fn open_type(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"features": list, "figures": otf::figures(&list)}))
}
/// How much of the selected text `type.selectionAttrs` reports.
const SELECTED_TEXT_CHARS: usize = 60;
fn selected_text_preview(chars: impl Iterator<Item = char>) -> String {
chars
.map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c })
.skip_while(|c| c.is_whitespace())
.take(SELECTED_TEXT_CHARS)
.collect()
}
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let targets = format_targets(s);
@@ -1108,13 +1211,115 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let pi = story.para_at(r.start);
let pf = &story.paras[pi];
let (pp, base) = st.doc.styles.resolve_para(pf);
let mut cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }.clone();
if let Some(f) = typing_format(s) {
apply_typing_format(&mut cf, f);
}
let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?;
let cp = st.doc.styles.resolve_char(&base, &cf);
let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars());
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()}))
}
/// The first target character, with any pending caret overrides. Keep character-style
/// inheritance separate from paragraph defaults for style creation from the selection.
pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result<designcraft_doc::CharFormat> {
let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone();
if let Some(pending) = typing_format(s)
&& target.story == pending.at.story
&& target.cell == pending.at.cell
&& target.range == pending.at.range()
{
apply_typing_format(&mut format, pending);
}
Ok(format)
}
/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it.
pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) {
let Some(uid) = s.active().map(|st| st.uid) else { return };
if let Some(pending) = &mut s.typing_format
&& pending.doc == uid
{
for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color]
.into_iter()
.flatten()
{
if name == from {
*name = to.to_string();
}
}
}
}
fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs {
CharAttrs {
fill: Some(cp.stroke.clone()),
fill_tint: Some(cp.stroke_tint),
stroke: Some(cp.fill.clone()),
stroke_tint: Some(cp.fill_tint),
..Default::default()
}
}
/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors.
fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result<Value> {
const ID: &str = "type.swapFillStroke";
let targets = format_targets(s);
if let [target] = targets.as_slice()
&& target.range.is_empty()
&& s.doc()?.selection.text.is_some()
{
let st = s.doc()?;
let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?;
let (_, base) = st.doc.styles.resolve_para(pf);
let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?);
return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint}));
}
let mut edits = Vec::new();
let d = &s.doc()?.doc;
for target in targets {
let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
if story.is_empty() {
let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?;
let (_, base) = d.styles.resolve_para(pf);
let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0)));
validate_char_fill(d, &attrs, ID)?;
edits.push((target, attrs));
continue;
}
let mut runs = story.runs().peekable();
for (pi, para) in story.para_ranges().into_iter().enumerate() {
let start = para.start.max(target.range.start);
let end = para.end.saturating_add(1).min(story.len()).min(target.range.end);
if start >= end {
continue;
}
let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?;
let (_, base) = d.styles.resolve_para(pf);
while let Some((range, format)) = runs.peek() {
if range.end <= start {
runs.next();
continue;
}
if range.start >= end {
break;
}
let attrs = swapped_colors(&d.styles.resolve_char(&base, format));
validate_char_fill(d, &attrs, ID)?;
edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs));
if range.end > end {
break;
}
runs.next();
}
}
}
s.edit(|d, _| {
for (target, attrs) in &edits {
apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]);
}
ok()
})
}
fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) {
@@ -1348,6 +1553,37 @@ mod open_type_tests {
s.execute("text.move", &json!({"dir": "right"})).unwrap();
}
#[test]
fn selected_text_preview_stops_at_its_character_budget() {
let read = std::cell::Cell::new(0usize);
let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK];
let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1));
let preview = super::selected_text_preview(chars);
assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS));
assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned");
assert_eq!(super::selected_text_preview("A\nB".chars()), "A B");
}
#[test]
fn selection_attrs_report_the_selected_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s
.execute(
"frame.create",
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue
What's new in typography this season, and why it matters to every designer"}),
)
.unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed");
s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap();
let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string();
assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}");
s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing");
}
#[test]
fn open_type_toggles_reach_the_text() {
let mut s = Session::new();
@@ -2286,3 +2522,44 @@ mod story_query_contract_tests {
assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], "");
}
}
#[cfg(test)]
mod sentence_selection_tests {
use super::{para_bounds, sentence_bounds};
fn sentence(s: &str, at: &str) -> String {
let i = s.find(at).unwrap();
let (a, b) = sentence_bounds(s, i);
s[a..b].to_string()
}
#[test]
fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() {
let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last";
assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. ");
assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} ");
assert_eq!(sentence(s, "Twice"), "(Twice!) ");
assert_eq!(sentence(s, "Yes"), "Yes\u{2026} ");
assert_eq!(sentence(s, "fine"), "fine.");
assert_eq!(sentence(s, "one"), "Next one. ");
assert_eq!(sentence(s, "Last"), "Last");
// The caret at the very end of a paragraph is in its last sentence.
let end = s.find('\n').unwrap();
assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end));
}
#[test]
fn cjk_full_stops_end_sentences_without_a_space() {
let s = "これは文です。次の文!最後";
assert_eq!(sentence(s, "次"), "次の文!");
assert_eq!(sentence(s, "最後"), "最後");
}
#[test]
fn paragraphs_stop_at_returns() {
let s = "One.\nTwo two.\n";
assert_eq!(para_bounds(s, 6), (5, 13));
assert_eq!(para_bounds(s, 0), (0, 4));
assert_eq!(para_bounds(s, s.len()), (s.len(), s.len()));
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Cross-command caret color and character-style regressions.
use crate::Session;
use designcraft_doc::StoryId;
use serde_json::{Value, json};
fn setup(text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [36,36,300,200], "content":"text", "text":text})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story":sid,"anchor":text.len()})).unwrap();
(s, sid)
}
fn attrs(s: &mut Session) -> Value {
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"].clone()
}
#[test]
fn integration_color_character_style_captures_pending_and_null() {
let (mut s, sid) = setup("hello");
s.execute("type.char", &json!({"size":30,"tracking":40,"fill":"[Paper]"})).unwrap();
s.execute("style.character.create", &json!({"name":"Pending","fromSelection":true,"chars":{"tracking":null}})).unwrap();
let a = &s.doc().unwrap().doc.styles.char_style("Pending").unwrap().chars;
assert_eq!(a.size, Some(30.0));
assert_eq!(a.fill.as_deref(), Some("[Paper]"));
assert_eq!(a.tracking, None);
assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "hello");
}
#[test]
fn integration_color_pending_follows_promoted_swatch() {
for target in ["fill", "stroke"] {
let (mut s, sid) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456","target":target})).unwrap()["swatch"].clone();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
assert_eq!(attrs(&mut s)[target], "Brand");
s.execute("text.insert", &json!({"text":"X"})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
let f = &st.format_after(5).over;
assert_eq!(if target == "fill" { f.fill.as_deref() } else { f.stroke.as_deref() }, Some("Brand"));
}
}
#[test]
fn integration_color_undo_discards_missing_pending_swatch() {
let (mut s, sid) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.swatch(&old).is_none());
assert_ne!(attrs(&mut s)["fill"], old);
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert_ne!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(5).over.fill.as_deref(), Some(old.as_str()));
}
#[test]
fn integration_color_empty_story_uses_one_undo_step() {
let (mut s, sid) = setup("");
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
let n = s.doc().unwrap().history.undo.len();
s.execute("object.color", &json!({"color":"#123456"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
let colored = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), colored);
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(0).over.fill.is_some());
}
#[test]
fn integration_color_swap_resolves_each_paragraph_and_keeps_unselected_text() {
let (mut s, sid) = setup("ab\ncd");
for (name, fill, stroke, ft, st, start, end) in
[("First", "[Paper]", "[Black]", 0.2, 0.8, 0, 2), ("Second", "[Black]", "[Paper]", 0.4, 0.6, 3, 5)]
{
s.execute("style.paragraph.create", &json!({"name":name,"chars":{"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st}})).unwrap();
s.execute("text.select", &json!({"story":sid,"anchor":start,"focus":end})).unwrap();
s.execute("style.paragraph.apply", &json!({"name":name})).unwrap();
}
s.execute("text.select", &json!({"story":sid,"anchor":1,"focus":4})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
let n = s.doc().unwrap().history.undo.len();
s.execute("type.swapFillStroke", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
for (pos, fill, stroke, ft, st) in [
(0, "[Paper]", "[Black]", 0.2, 0.8),
(1, "[Black]", "[Paper]", 0.8, 0.2),
(2, "[Black]", "[Paper]", 0.8, 0.2),
(3, "[Paper]", "[Black]", 0.6, 0.4),
(4, "[Black]", "[Paper]", 0.4, 0.6),
] {
s.execute("text.select", &json!({"story":sid,"anchor":pos,"focus":pos+1})).unwrap();
let a = attrs(&mut s);
assert_eq!(a["fill"], fill);
assert_eq!(a["stroke"], stroke);
assert!((a["fillTint"].as_f64().unwrap() - ft).abs() < 1e-6);
assert!((a["strokeTint"].as_f64().unwrap() - st).abs() < 1e-6);
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
}
#[test]
fn integration_color_swap_caret_applies_only_to_next_typing() {
let (mut s, sid) = setup("hello");
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"[Black]","fillTint":0.2,"strokeTint":0.8})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("type.swapFillStroke", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
let a = attrs(&mut s);
assert_eq!(a["fill"], "[Black]");
assert_eq!(a["stroke"], "[Paper]");
s.execute("text.insert", &json!({"text":"X"})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
assert_eq!(st.text, "helloX");
assert_eq!(st.format_after(0).over.fill, None);
assert_eq!(st.format_after(5).over.stroke.as_deref(), Some("[Paper]"));
assert_eq!(st.format_after(5).over.fill_tint, Some(0.8));
}
#[test]
fn integration_color_swap_selected_empty_cells_keeps_each_format() {
let (mut s, sid) = setup("hello");
let result = s.execute("table.insert", &json!({"rows":1,"cols":2})).unwrap();
let tid = result["table"].as_u64().unwrap();
s.edit(|d, _| {
let table = std::sync::Arc::make_mut(d.story_mut(StoryId(sid)).unwrap().tables.get_mut(&tid).unwrap());
for (c, fill, stroke) in [(0, "[Paper]", "[Black]"), (1, "[Black]", "[Paper]")] {
let attrs = &mut table.cell_mut(0, c).unwrap().text.chars[0].format.over;
attrs.fill = Some(fill.into());
attrs.stroke = Some(stroke.into());
}
Ok(())
})
.unwrap();
s.execute("table.select", &json!({"story":sid,"table":tid,"what":"table"})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("type.swapFillStroke", &json!({})).unwrap();
let table = &s.doc().unwrap().doc.story(StoryId(sid)).unwrap().tables[&tid];
assert_eq!(table.cell(0, 0).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Black]"));
assert_eq!(table.cell(0, 1).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Paper]"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
}
#[test]
fn integration_color_failed_promotion_preserves_pending_and_history() {
let (mut s, _) = setup("hello");
s.execute("swatch.create", &json!({"name":"Brand","color":"#654321"})).unwrap();
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
let before = s.doc().unwrap().clone();
let pending = attrs(&mut s);
assert!(s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).is_err());
assert_eq!(attrs(&mut s), pending);
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
}
#[test]
fn integration_color_promote_undo_redo_never_reuses_a_missing_pending_name() {
let (mut s, _) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
s.execute("edit.redo", &json!({})).unwrap();
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
}
#[test]
fn integration_color_swap_validates_new_fill_before_any_edit() {
let (mut s, sid) = setup("hello");
s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"Missing Ink"})).unwrap();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let error = s.execute("type.swapFillStroke", &json!({})).unwrap_err().to_string();
assert!(error.contains("Missing Ink"), "{error}");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
assert_eq!(s.journal, journal);
}
+18 -1
View File
@@ -52,9 +52,18 @@ fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) {
return Err(bad(cmd, format!("{id} is not a text frame")));
}
check_editable(&st.doc, id, cmd)?;
Ok(id)
}
fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(cmd, format!("{id} is locked or on a locked layer")));
}
Ok(())
}
/// A rect param made safe for a frame: finite, ordered, at least half a point each way.
fn frame_rect(p: &Value) -> Result<Option<Rect>> {
let Some(r) = rect_param(p, "rect") else { return Ok(None) };
@@ -68,7 +77,7 @@ fn frame_rect(p: &Value) -> Result<Option<Rect>> {
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?);
let sr = spread_param(p, "spread");
let sr = spread_param(p, "spread")?;
let autoflow = bool_or(p, "autoflow", false);
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
@@ -114,6 +123,10 @@ fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
"before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?,
other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))),
};
check_editable(d0, at, CMD)?;
if let Some(next) = d0.next_frame(at) {
check_editable(d0, next, CMD)?;
}
s.edit(|d, _| {
d.unthread_after(at)?;
let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?;
@@ -123,6 +136,10 @@ fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
fn remove_from_thread(s: &mut Session, p: &Value) -> Result<Value> {
let frame = frame_param(s, p, "text.removeFromThread")?;
let d = &s.doc()?.doc;
for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() {
check_editable(d, neighbour, "text.removeFromThread")?;
}
s.edit(|d, _| {
d.remove_from_thread(frame)?;
Ok(json!({}))
+58 -1
View File
@@ -129,6 +129,63 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
/// A parent-page page-number marker follows Numbering & Section Options: changing a section's
/// start number renames its pages without changing their index or the number of sections, and
/// the marker composed for a page shows the new name.
#[test]
fn parent_page_number_follows_section_renumbering() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
let marker = designcraft_doc::story::PAGE_NUMBER.to_string();
let r = s
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker}))
.unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker);
let shown = |s: &Session, page: usize| -> usize {
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, Some(page));
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
};
assert_eq!(shown(&s, 2), 1, "page 3");
s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap();
assert_eq!(s.doc().unwrap().doc.page_name(2), "102");
assert_eq!(shown(&s, 2), 3, "page 102 after renumbering");
}
/// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running
/// header is composed for the page it is shown on, not the first page with that name.
#[test]
fn running_header_on_parent_page_with_repeated_page_numbers() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap();
s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap();
let names: Vec<String> = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect();
assert_eq!(names, ["1", "2", "1", "2"]);
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
// The only heading is on the third page (the second section's page 1).
let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).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": 1})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap();
let r = s
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""}))
.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": 0})).unwrap();
s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap();
let d = s.doc().unwrap().doc.clone();
let shown = |page: usize| -> usize {
let cs = s.cache.get(&d, sid, Some(page));
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
};
assert_eq!(shown(0), 0, "no heading on or before the first page");
assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading");
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
}
#[test]
fn running_header_on_parent_page_follows_headings() {
let mut s = Session::new();
@@ -158,7 +215,7 @@ mod tests {
assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)));
// Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3".
let shown = |page: usize| -> usize {
let cs = s.cache.get(&d, sid, Some(&d.page_name(page)));
let cs = s.cache.get(&d, sid, Some(page));
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
};
let (p1, p2, p3) = (shown(0), shown(1), shown(2));
+1 -1
View File
@@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"text": text, "bytes": text.len()})),
+12 -2
View File
@@ -7,6 +7,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(not(target_arch = "wasm32"))]
pub mod atomic;
pub mod cmd;
pub mod dtd;
pub mod guard;
@@ -91,6 +93,9 @@ pub struct DocState {
/// Editing parent spreads (Pages panel double-click on a parent).
pub editing_parents: bool,
pub uid: u64,
/// Recovery entries this state actually opened or published, for repeated-open and cleanup.
/// Bounded, deduplicated checkpoint addresses; never inferred from uid alone.
pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>,
/// Unfilled document while data-merge preview is showing a record. Not saved.
pub preview_stash: Option<Arc<Document>>,
/// 1-based record currently previewed.
@@ -129,6 +134,7 @@ impl DocState {
interaction: None,
editing_parents: false,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
recovery_entries: Vec::new(),
preview_stash: None,
preview_record: None,
fonts: None,
@@ -177,6 +183,8 @@ pub struct Prefs {
pub rich_black_output: bool,
/// Font menu favourites (family names).
pub favorite_fonts: Vec<String>,
/// Fonts picked in the font menus, most recent first (shown at the menus' top).
pub recent_fonts: Vec<String>,
/// Preferences › Type › Show Font Names in English: the font menus show CJK families by their
/// English names instead of their native ones. Documents store the English name either way.
pub show_font_names_in_english: bool,
@@ -233,6 +241,7 @@ impl Default for Prefs {
highlight_substituted_fonts: true,
rich_black_output: false,
favorite_fonts: Vec::new(),
recent_fonts: Vec::new(),
show_font_names_in_english: false,
smart_text_reflow: true,
autocorrect: false,
@@ -377,8 +386,9 @@ impl Session {
self.stop_data_preview();
}
#[cfg(not(target_arch = "wasm32"))]
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
recovery::discard(dir, d.uid);
{
let recovery_dir = self.recovery_dir.clone();
recovery::discard_document(self, i, recovery_dir.as_deref());
}
if i < self.docs.len() {
self.docs.remove(i);
File diff suppressed because it is too large. Load diff
+16 -2
View File
@@ -9,7 +9,8 @@
//!
//! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`),
//! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and
//! lines starting with `#` or `//` are ignored.
//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are
//! accepted.
use serde_json::{Value, json};
@@ -22,7 +23,9 @@ pub struct Step {
/// Parse script text (see the module docs).
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
let t = text.trim();
// Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the
// first command id.
let t = text.trim_start_matches('\u{feff}').trim();
if t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
return v
@@ -174,6 +177,17 @@ mod tests {
assert!(parse("frame.create {bad").unwrap_err().contains("line 1"));
}
/// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM
/// became part of the first command id (#654).
#[test]
fn a_leading_bom_and_crlf_parse_like_plain_text() {
let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n";
let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n"));
assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap());
let array = r#"[{"command": "file.new"}]"#;
assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap());
}
#[test]
fn resolves_references() {
let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})];
+122
View File
@@ -399,3 +399,125 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() {
}
}
}
#[test]
fn split_selected_row_or_column_splits_both_cells() {
for horizontal in [true, false] {
let (mut s, sid, _) = session_with_frame();
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let params = if horizontal { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) };
s.execute("table.select", &params).unwrap();
let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" };
s.execute(command, &json!({})).unwrap();
let table = table_for(&s, sid, tid);
assert_eq!((table.nrows(), table.ncols()), if horizontal { (3, 2) } else { (2, 3) });
for r in 0..2 {
for c in 0..2 {
assert_eq!(table.owner(r, c), (r, c), "both selected cells have independent halves");
}
}
check(&s);
}
}
#[test]
fn split_selected_block_and_merged_cells_preserves_geometry_and_history() {
for horizontal in [true, false] {
for merged in [false, true] {
let (mut s, sid, _) = session_with_frame();
let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap();
s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap();
s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap();
s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap();
s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap();
for r in 0..4 {
for c in 0..4 {
s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap();
}
}
let range = if !merged {
doc::CellRange::new(1, 1, 2, 2)
} else if horizontal {
doc::CellRange::new(0, 0, 2, 1)
} else {
doc::CellRange::new(0, 0, 1, 2)
};
let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]});
s.execute("table.select", &selection).unwrap();
s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap();
if merged {
let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) };
s.execute("table.merge", &merge).unwrap();
s.execute("table.select", &selection).unwrap();
}
let before_doc = s.doc().unwrap().doc.clone();
let before_selection = s.doc().unwrap().selection.clone();
let before = table_for(&s, sid, tid).clone();
let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None);
let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap();
let undo_len = s.doc().unwrap().history.undo.len();
let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" };
s.execute(command, &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry");
let after_doc = s.doc().unwrap().doc.clone();
let after_selection = s.doc().unwrap().selection.clone();
let after = table_for(&s, sid, tid).clone();
let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None);
let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap();
assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table");
assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once");
for placed in &before_fragment.cells {
let original = before.cell(placed.row, placed.col).unwrap();
let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect();
assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source);
let surviving = matches[0];
let cell = after.cell(surviving.row, surviving.col).unwrap();
let mut normalized = cell.clone();
normalized.row_span = original.row_span;
normalized.col_span = original.col_span;
assert_eq!(&normalized, original, "existing cell text and formatting are preserved");
if !range.contains(placed.row, placed.col) {
assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged");
continue;
}
let second_addr = if horizontal {
(surviving.row + cell.row_span as usize, surviving.col)
} else {
(surviving.row, surviving.col + cell.col_span as usize)
};
let second = after.cell(second_addr.0, second_addr.1).unwrap();
assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr);
assert!(second.text.is_empty());
assert_eq!(second.style, original.style);
assert_eq!(second.insets, original.insets);
assert_eq!(second.fill, original.fill);
assert_eq!(second.fill_tint, original.fill_tint);
let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect;
assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell");
if horizontal {
assert_eq!(surviving.rect.y1, second_rect.y0);
} else {
assert_eq!(surviving.rect.x1, second_rect.x0);
}
}
let text = s.doc().unwrap().selection.text.unwrap();
let address = text.cell.unwrap();
assert_eq!(after.owner(address.row, address.col), (address.row, address.col));
assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len());
let cells = s.doc().unwrap().selection.cells.unwrap();
assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols());
check(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc));
assert_eq!(s.doc().unwrap().selection, before_selection);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len);
s.execute("edit.redo", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc));
assert_eq!(s.doc().unwrap().selection, after_selection);
let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc);
let reopened = crate::cmd::file_from(&bytes).unwrap();
reopened.check().unwrap();
assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after);
}
}
}
+188
View File
@@ -0,0 +1,188 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
fn dir(name: &str) -> tempfile::TempDir {
tempfile::Builder::new().prefix(&format!("dc-book-safety-{name}-")).tempdir().unwrap()
}
fn document(start: u32, custom: bool) -> designcraft_doc::Document {
let mut s = Session::new();
s.execute("file.new", &json!({"pages":2})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
d.sections.first_mut().unwrap().start_number = Some(start);
if custom {
let mut style = d.styles.paragraph.get(1).unwrap().clone();
style.name = "Synthetic Book Style".into();
d.styles_mut().paragraph.push(style);
}
d
}
fn native(p: &Path, start: u32, custom: bool) {
std::fs::write(p, designcraft_format::save(&document(start, custom)).unwrap()).unwrap();
}
fn idml(p: &Path, start: u32) {
std::fs::write(p, designcraft_idml::export_idml(&document(start, false))).unwrap();
assert!(designcraft_idml::import_idml(&std::fs::read(p).unwrap()).is_ok());
}
fn book(d: &Path, chapters: &[&Path]) -> Session {
let mut s = Session::new();
s.execute("book.new", &json!({"path":d.join("book.dcbook").to_string_lossy()})).unwrap();
for p in chapters {
s.execute("book.add", &json!({"path":p.to_string_lossy()})).unwrap();
}
s
}
#[test]
fn late_missing_chapter_does_not_partially_paginate_earlier_chapters() {
let d = dir("late-missing");
let first = d.path().join("first.designcraft");
let later = d.path().join("later.designcraft");
native(&first, 9, false);
native(&later, 1, false);
let mut s = book(d.path(), &[&first, &later]);
let before = std::fs::read(&first).unwrap();
std::fs::remove_file(later).unwrap();
let result = s.execute("book.paginate", &json!({}));
assert!(result.is_err());
let after = std::fs::read(&first).unwrap();
assert!(
before == after,
"book.paginate changed the first chapter before error: {result:?}; new first number {:?}",
designcraft_format::load(&after).unwrap().sections.first().unwrap().start_number
);
}
#[test]
fn late_corrupt_chapter_does_not_partially_sync_earlier_chapters() {
let d = dir("late-corrupt");
let source = d.path().join("source.designcraft");
let first = d.path().join("first.designcraft");
let later = d.path().join("later.designcraft");
native(&source, 1, true);
native(&first, 1, false);
native(&later, 1, false);
let mut s = book(d.path(), &[&source, &first, &later]);
let before = std::fs::read(&first).unwrap();
std::fs::write(later, b"not a document").unwrap();
let result = s.execute("book.syncStyles", &json!({}));
assert!(result.is_err());
let after = std::fs::read(&first).unwrap();
assert!(
before == after,
"book.syncStyles changed the first target before error: {result:?}; copied custom style {}",
designcraft_format::load(&after).unwrap().styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style")
);
}
#[test]
fn paginate_does_not_replace_imported_idml_with_native_format() {
let d = dir("idml-paginate");
let chapter = d.path().join("chapter.idml");
idml(&chapter, 9);
let mut s = book(d.path(), &[&chapter]);
let before = std::fs::read(&chapter).unwrap();
let result = s.execute("book.paginate", &json!({}));
let after = std::fs::read(&chapter).unwrap();
assert!(
before == after,
"book.paginate rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}",
designcraft_idml::import_idml(&after).is_ok(),
designcraft_format::load(&after).is_ok()
);
}
#[test]
fn style_sync_does_not_replace_imported_idml_with_native_format() {
let d = dir("idml-sync");
let source = d.path().join("source.designcraft");
let chapter = d.path().join("chapter.idml");
native(&source, 1, true);
idml(&chapter, 1);
let mut s = book(d.path(), &[&source, &chapter]);
let before = std::fs::read(&chapter).unwrap();
let result = s.execute("book.syncStyles", &json!({}));
let after = std::fs::read(&chapter).unwrap();
assert!(
before == after,
"book.syncStyles rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}",
designcraft_idml::import_idml(&after).is_ok(),
designcraft_format::load(&after).is_ok()
);
}
#[test]
fn an_open_unsaved_chapter_blocks_batch_writes_before_any_files_change() {
let d = dir("open-unsaved");
let first = d.path().join("first.designcraft");
let later = d.path().join("later.designcraft");
native(&first, 9, false);
native(&later, 5, false);
let mut s = book(d.path(), &[&first, &later]);
s.execute("file.open", &json!({"path":later.to_string_lossy()})).unwrap();
s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":"Unsaved chapter edit"})).unwrap();
let first_before = std::fs::read(&first).unwrap();
let later_before = std::fs::read(&later).unwrap();
let revision = s.doc().unwrap().revision;
let result = s.execute("book.paginate", &json!({}));
assert!(
result.is_err(),
"book.paginate accepted an open dirty chapter: {result:?}; first changed {}, open backing file changed {}",
std::fs::read(&first).unwrap() != first_before,
std::fs::read(&later).unwrap() != later_before
);
assert_eq!(std::fs::read(&first).unwrap(), first_before);
assert_eq!(std::fs::read(&later).unwrap(), later_before);
assert_eq!(s.doc().unwrap().revision, revision);
assert!(s.doc().unwrap().is_dirty());
let e = result.unwrap_err().to_string();
assert!(e.contains("later.designcraft") && e.contains("close"), "{e}");
}
#[cfg(unix)]
#[test]
fn an_alias_of_an_open_chapter_blocks_all_batch_writes() {
let d = dir("open-alias");
let first = d.path().join("first.designcraft");
let real = d.path().join("real.designcraft");
let alias = d.path().join("alias.designcraft");
native(&first, 9, false);
native(&real, 5, false);
std::os::unix::fs::symlink("real.designcraft", &alias).unwrap();
let mut s = book(d.path(), &[&first, &alias]);
s.execute("file.open", &json!({"path":real.to_string_lossy()})).unwrap();
let first_before = std::fs::read(&first).unwrap();
let real_before = std::fs::read(&real).unwrap();
let result = s.execute("book.paginate", &json!({}));
assert!(result.is_err(), "book.paginate accepted alias of open chapter: {result:?}");
assert_eq!(std::fs::read(&first).unwrap(), first_before);
assert_eq!(std::fs::read(&real).unwrap(), real_before);
let e = result.unwrap_err().to_string();
assert!(e.contains("alias.designcraft") && e.contains("close"), "{e}");
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
#[test]
fn imported_idml_remains_a_read_only_style_source() {
let d = dir("idml-style-source");
let source = d.path().join("source.idml");
let target = d.path().join("target.designcraft");
std::fs::write(&source, designcraft_idml::export_idml(&document(1, true))).unwrap();
native(&target, 1, false);
let mut s = book(d.path(), &[&source, &target]);
let before = std::fs::read(&source).unwrap();
let result = s.execute("book.syncStyles", &json!({})).unwrap();
assert_eq!(result["synced"], 1);
assert_eq!(std::fs::read(&source).unwrap(), before);
assert!(designcraft_idml::import_idml(&before).is_ok());
let target = designcraft_format::load(&std::fs::read(target).unwrap()).unwrap();
assert!(target.styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style"));
}
#[test]
fn imported_idml_still_exports_to_book_pdf_without_source_changes() {
let d = dir("idml-pdf");
let chapter = d.path().join("chapter.idml");
idml(&chapter, 1);
let mut s = book(d.path(), &[&chapter]);
let before = std::fs::read(&chapter).unwrap();
let result = s.execute("book.exportPdf", &json!({})).unwrap();
assert_eq!(result["pages"], 2);
assert!(result["base64"].as_str().is_some_and(|s| !s.is_empty()));
assert_eq!(std::fs::read(chapter).unwrap(), before);
}
@@ -0,0 +1,274 @@
//! Character Styles panel's existing `fromSelection` command contract.
use designcraft_doc::{CharAttrs, StoryId};
use designcraft_engine::Session;
use serde_json::{Value, json};
fn story(text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap();
(s, frame["story"].as_u64().unwrap())
}
fn select(s: &mut Session, sid: u64, anchor: usize, focus: usize) {
s.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap();
}
fn chars(s: &Session, name: &str) -> CharAttrs {
s.doc().unwrap().doc.styles.char_style(name).unwrap().chars.clone()
}
fn document(s: &Session) -> Value {
serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap()
}
#[test]
fn character_capture_overlays_style_chain_local_and_explicit_attrs_but_inherits_paragraph() {
let (mut s, sid) = story("alpha\nbeta");
s.execute("style.paragraph.create", &json!({"name": "Source paragraph", "chars": {"size": 18}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"size": 30, "underline": true}})).unwrap();
s.execute("style.character.create", &json!({"name": "Base", "chars": {"underline": true, "tracking": 10, "strikethrough": true}})).unwrap();
s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"fontStyle": "Italic"}})).unwrap();
select(&mut s, sid, 0, 5);
s.execute("style.paragraph.apply", &json!({"name": "Source paragraph"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap();
s.execute("type.char", &json!({"tracking": 20, "underline": false})).unwrap();
s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true, "chars": {"tracking": 7}})).unwrap();
assert_eq!(
chars(&s, "Captured"),
CharAttrs { font_style: Some("Italic".into()), underline: Some(false), tracking: Some(7.0), strikethrough: Some(true), ..Default::default() }
);
select(&mut s, sid, 6, 10);
s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
let resolved = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(resolved["chars"]["fontStyle"], "Italic");
assert_eq!(resolved["chars"]["strikethrough"], true);
assert_eq!(resolved["chars"]["underline"], false);
assert_eq!(resolved["chars"]["tracking"], 7.0);
assert_eq!(resolved["chars"]["size"], 30.0);
s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"size": 34}})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 34.0);
// The captured attributes are a snapshot, independent of future source-style edits.
s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"fontStyle": "Bold"}})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Italic");
}
#[test]
fn character_capture_reproducer_is_one_undo_step_and_survives_native_reopen() {
let (mut s, sid) = story("alpha beta");
select(&mut s, sid, 0, 5);
s.execute("type.char", &json!({"underline": true})).unwrap();
let before = document(&s);
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let source = serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap();
s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap();
assert_eq!(chars(&s, "Captured"), CharAttrs { underline: Some(true), ..Default::default() });
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
assert_eq!(s.doc().unwrap().selection, selection);
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(), source);
let after = document(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&s), before);
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&s), after);
assert_eq!(s.doc().unwrap().selection, selection);
// Both commands use the native ZIP format, without a filesystem fixture.
let saved = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name": "capture.designcraft", "base64": saved["base64"]})).unwrap();
assert_eq!(chars(&reopened, "Captured"), chars(&s, "Captured"));
select(&mut reopened, sid, 6, 10);
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], false);
let before_apply = document(&reopened);
reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], true);
assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta");
let after_apply = document(&reopened);
reopened.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&reopened), before_apply);
reopened.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&reopened), after_apply);
}
#[test]
fn character_capture_matches_paragraph_selection_and_caret_boundary_policy() {
let (mut s, sid) = story("éX");
select(&mut s, sid, 0, 2);
s.execute("type.char", &json!({"underline": true})).unwrap();
select(&mut s, sid, 2, 3);
s.execute("type.char", &json!({"underline": false})).unwrap();
// A selected character takes the run on the right; a caret takes the typing run on the left.
// Reversed and mixed selections start at the lowest byte offset, just like paragraph capture.
for (anchor, focus, underlined) in [(3, 2, false), (2, 2, true), (3, 0, true)] {
select(&mut s, sid, anchor, focus);
let p = s.execute("style.paragraph.create", &json!({"name": "Paragraph", "fromSelection": true})).unwrap();
let c = s.execute("style.character.create", &json!({"name": "Character", "fromSelection": true})).unwrap();
let p = s.doc().unwrap().doc.styles.para(p["name"].as_str().unwrap()).unwrap();
let c = chars(&s, c["name"].as_str().unwrap());
assert_eq!(c.underline, Some(underlined));
assert_eq!(c.underline, p.chars.underline);
assert_eq!(c.size, None, "unlike paragraph capture, character capture must not freeze paragraph defaults");
}
}
#[test]
fn character_capture_is_opt_in_and_no_text_uses_only_explicit_chars() {
let (mut s, sid) = story("alpha");
select(&mut s, sid, 0, 5);
s.execute("type.char", &json!({"underline": true})).unwrap();
for options in [json!({"name": "Plain"}), json!({"name": "Plain", "fromSelection": false})] {
let created = s.execute("style.character.create", &options).unwrap();
assert!(chars(&s, created["name"].as_str().unwrap()).is_empty());
}
assert!(s.doc().unwrap().doc.styles.char_style("Plain 2").is_some());
s.execute("selection.set", &json!({"ids": []})).unwrap();
s.execute("style.character.create", &json!({"name": "No target", "fromSelection": true, "basedOn": "Plain", "chars": {"underline": false}}))
.unwrap();
assert_eq!(chars(&s, "No target"), CharAttrs { underline: Some(false), ..Default::default() });
assert_eq!(s.doc().unwrap().doc.styles.char_style("No target").unwrap().based_on.as_deref(), Some("Plain"));
let (mut empty, empty_sid) = story("");
select(&mut empty, empty_sid, 0, 0);
empty.execute("style.character.create", &json!({"name": "Empty", "fromSelection": true})).unwrap();
assert!(chars(&empty, "Empty").is_empty());
}
#[test]
fn character_capture_retains_deleted_fill_and_reuses_it_after_native_reopen() {
for named_source in [false, true] {
let (mut s, sid) = story("alpha beta gamma");
s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap();
select(&mut s, sid, 0, 5);
if named_source {
s.execute("style.character.create", &json!({"name": "Base", "chars": {"fill": "Ink"}})).unwrap();
s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Source"})).unwrap();
} else {
s.execute("type.char", &json!({"fill": "Ink"})).unwrap();
}
s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap();
s.doc().unwrap().doc.check().unwrap();
let before = document(&s);
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap();
assert_eq!(chars(&s, "Captured"), CharAttrs { fill: Some("Ink".into()), ..Default::default() });
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
assert_eq!(s.doc().unwrap().selection, selection);
let after = document(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&s), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&s), after);
// Non-fill overrides do not turn an existing captured reference into explicit input.
s.execute("style.character.create", &json!({"name": "Tweaked", "fromSelection": true, "chars": {"underline": false, "tracking": 0}}))
.unwrap();
assert_eq!(chars(&s, "Tweaked"), CharAttrs { fill: Some("Ink".into()), underline: Some(false), tracking: Some(0.0), ..Default::default() });
s.execute("style.character.create", &json!({"name": "Paper", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap();
assert_eq!(chars(&s, "Paper"), CharAttrs { fill: Some("[Paper]".into()), ..Default::default() });
s.execute("style.character.create", &json!({"name": "Cleared fill", "fromSelection": true, "chars": {"fill": null}})).unwrap();
assert!(chars(&s, "Cleared fill").is_empty());
select(&mut s, sid, 6, 10);
s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink");
s.doc().unwrap().doc.check().unwrap();
let saved = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name": "deleted-fill.designcraft", "base64": saved["base64"]})).unwrap();
reopened.doc().unwrap().doc.check().unwrap();
for name in ["Captured", "Tweaked", "Paper", "Cleared fill"] {
assert_eq!(chars(&reopened, name), chars(&s, name));
}
select(&mut reopened, sid, 6, 10);
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink");
select(&mut reopened, sid, 11, 16);
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]");
reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink");
reopened.execute("style.character.apply", &json!({"name": "Cleared fill"})).unwrap();
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]");
assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta gamma");
}
}
#[test]
fn character_capture_rejects_explicit_missing_fills_without_losing_redo() {
let (mut s, sid) = story("alpha");
s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap();
select(&mut s, sid, 0, 5);
s.execute("type.char", &json!({"fill": "Ink"})).unwrap();
s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap();
s.execute("style.character.create", &json!({"name": "Redo sentinel"})).unwrap();
let redo_doc = s.doc().unwrap().doc.clone();
let redo_selection = s.doc().unwrap().selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
for (capture, fill) in [(true, "Missing"), (true, "Ink"), (false, "Missing")] {
let error = s.execute("style.character.create", &json!({"name": "Invalid", "fromSelection": capture, "chars": {"fill": fill}})).unwrap_err();
assert!(error.to_string().contains(fill));
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().selection, before.selection);
assert_eq!(s.doc().unwrap().revision, before.revision);
assert_eq!(format!("{:?}", s.doc().unwrap().history), format!("{:?}", before.history));
assert_eq!(s.journal, journal);
}
s.execute("edit.redo", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &redo_doc));
assert_eq!(s.doc().unwrap().selection, redo_selection);
assert!(s.doc().unwrap().doc.styles.char_style("Redo sentinel").is_some());
}
#[test]
fn character_capture_explicit_null_restores_live_paragraph_inheritance() {
for named_source in [false, true] {
let (mut s, sid) = story("alpha\nbeta");
select(&mut s, sid, 0, 5);
let source = json!({"tracking": 20, "underline": true, "underlineWeight": 1.5, "fontStyle": "Italic"});
if named_source {
s.execute("style.character.create", &json!({"name": "Base", "chars": source})).unwrap();
s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Source"})).unwrap();
} else {
s.execute("type.char", &source).unwrap();
}
// Omitting chars still captures values, including a nullable attribute's numeric value.
s.execute("style.character.create", &json!({"name": "Unchanged", "fromSelection": true})).unwrap();
assert_eq!(chars(&s, "Unchanged").tracking, Some(20.0));
assert_eq!(chars(&s, "Unchanged").underline_weight, Some(Some(1.5)));
let before = document(&s);
let undo_len = s.doc().unwrap().history.undo.len();
s.execute(
"style.character.create",
&json!({"name": "Cleared", "fromSelection": true,
"chars": {"tracking": null, "underlineWeight": null, "underline": false, "baselineShift": 0}}),
)
.unwrap();
assert_eq!(
chars(&s, "Cleared"),
CharAttrs { font_style: Some("Italic".into()), underline: Some(false), baseline_shift: Some(0.0), ..Default::default() }
);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
let after = document(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&s), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&s), after);
s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"tracking": 30, "underlineWeight": 2}})).unwrap();
select(&mut s, sid, 6, 10);
s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Cleared"})).unwrap();
let effective = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(effective["chars"]["tracking"], 30.0);
assert_eq!(effective["chars"]["underlineWeight"], 2.0);
assert_eq!(effective["chars"]["underline"], false);
s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"tracking": 40, "underlineWeight": 3}})).unwrap();
let effective = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(effective["chars"]["tracking"], 40.0);
assert_eq!(effective["chars"]["underlineWeight"], 3.0);
}
}
@@ -0,0 +1,87 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
fn library_failure(command: &str) {
let temp = tempfile::tempdir().unwrap();
let folder = temp.path().join("library");
std::fs::create_dir(&folder).unwrap();
let file = folder.join("items.dclib");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap();
s.execute("library.new", &json!({"path": file})).unwrap();
s.execute("library.add", &json!({"name": "Keep"})).unwrap();
let before = serde_json::to_value(&s.library).unwrap();
let path = s.library.as_ref().unwrap().path.clone();
let journal = s.journal.clone();
let original_bytes = std::fs::read(&file).unwrap();
let kept = temp.path().join("kept-library");
std::fs::rename(&folder, &kept).unwrap();
assert!(s.execute(command, &json!({"name": "New", "index": 0})).is_err());
assert_eq!(before, serde_json::to_value(&s.library).unwrap(), "a failed library write must retain its original items");
assert_eq!(path, s.library.as_ref().unwrap().path);
assert_eq!(journal, s.journal);
assert_eq!(original_bytes, std::fs::read(kept.join("items.dclib")).unwrap());
}
#[test]
fn failed_library_add_keeps_existing_items() {
library_failure("library.add");
}
#[test]
fn failed_library_remove_keeps_existing_items() {
library_failure("library.remove");
}
fn book_failure(command: &str) {
let temp = tempfile::tempdir().unwrap();
let mut s = Session::new();
let mut chapters = Vec::new();
for i in 0..3 {
s.execute("file.new", &json!({})).unwrap();
let path = temp.path().join(format!("chapter-{i}.designcraft"));
s.execute("file.saveAs", &json!({"path": path})).unwrap();
chapters.push(path);
}
let folder = temp.path().join("book");
std::fs::create_dir(&folder).unwrap();
let file = folder.join("chapters.dcbook");
s.execute("book.new", &json!({"path": file})).unwrap();
s.execute("book.add", &json!({"path": chapters[0]})).unwrap();
s.execute("book.add", &json!({"path": chapters[1]})).unwrap();
s.execute("book.styleSource", &json!({"index": 1})).unwrap();
let before = serde_json::to_value(&s.book).unwrap();
let path = s.book.as_ref().unwrap().path.clone();
let journal = s.journal.clone();
let original_bytes = std::fs::read(&file).unwrap();
let kept = temp.path().join("kept-book");
std::fs::rename(&folder, &kept).unwrap();
let params = if command == "book.add" { json!({"path": chapters[2]}) } else { json!({"index": 0}) };
assert!(s.execute(command, &params).is_err());
assert_eq!(before, serde_json::to_value(&s.book).unwrap(), "a failed book write must retain its original chapter list and style source");
assert_eq!(path, s.book.as_ref().unwrap().path);
assert_eq!(journal, s.journal);
assert_eq!(original_bytes, std::fs::read(kept.join("chapters.dcbook")).unwrap());
}
#[test]
fn failed_book_add_keeps_existing_chapters() {
book_failure("book.add");
}
#[test]
fn failed_book_remove_keeps_existing_chapters() {
book_failure("book.remove");
}
#[test]
fn failed_book_style_source_keeps_existing_source() {
book_failure("book.styleSource");
}
+128
View File
@@ -0,0 +1,128 @@
//! Distribution preserves explicit item locks without changing reference/spacing calculations.
use std::sync::Arc;
use designcraft_doc::ItemId;
use designcraft_engine::Session;
use designcraft_geom::Affine;
use serde_json::{Value, json};
const STARTS: [f64; 4] = [10.0, 70.0, 150.0, 310.0];
const SIZES: [f64; 4] = [20.0, 30.0, 40.0, 50.0];
// An original, synthetic 1x1 RGBA PNG; used by both a locked and an unlocked frame.
const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGPYYqPxHwAE2AIYBVnQbwAAAABJRU5ErkJggg==";
fn fixture(horizontal: bool, locks: [bool; 4]) -> (Session, Vec<u64>) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mut ids = Vec::new();
for (i, (&start, &size)) in STARTS.iter().zip(&SIZES).enumerate() {
let cross = 40.0 + i as f64 * 35.0;
let rect = if horizontal { [start, cross, start + size, cross + 25.0] } else { [cross, start, cross + 25.0, start + size] };
let graphic = i == 1 || i == 2;
let created =
s.execute("frame.create", &json!({"rect": rect, "content": if graphic {"unassigned"} else {"text"}, "text": "Keep this text"})).unwrap();
let id = created["id"].as_u64().unwrap();
ids.push(id);
if graphic {
s.execute("file.place", &json!({"frame": id, "base64": PIXEL, "name": "pixel.png"})).unwrap();
}
}
// An unselected control must retain all geometry and content too.
s.execute("frame.create", &json!({"rect": [400, 400, 450, 450], "content": "text", "text": "Outside selection"})).unwrap();
s.execute("object.fill", &json!({"ids": ids, "swatch": "[Black]", "tint": 0.5})).unwrap();
let locked: Vec<u64> = ids.iter().zip(locks).filter_map(|(&id, lock)| lock.then_some(id)).collect();
if !locked.is_empty() {
s.execute("object.lock", &json!({"ids": locked})).unwrap();
}
// Layers selection may include locked items. Deliberately avoid geometric order.
s.execute("selection.set", &json!({"ids": [ids[3], ids[1], ids[0], ids[2]]})).unwrap();
assert!(!s.doc().unwrap().doc.assets.is_empty());
s.doc().unwrap().doc.check().unwrap();
(s, ids)
}
fn modes() -> [(Value, [f64; 4]); 3] {
[
// Endpoint centers 20 and 335 give a center step of 105.
(json!({"by": "centers"}), [10.0, 110.0, 210.0, 310.0]),
// Outer span 350 minus total widths 140 gives three gaps of 70.
(json!({"by": "spacing"}), [10.0, 100.0, 200.0, 310.0]),
// An explicit spacing value selects fixed-gap mode without requiring `by`.
(json!({"spacing": 15}), [10.0, 45.0, 90.0, 145.0]),
]
}
fn check_distribution(horizontal: bool, mut params: Value, positions: [f64; 4], locks: [bool; 4], explicit_ids: bool) {
let (mut s, ids) = fixture(horizontal, locks);
params["axis"] = json!(if horizontal { "horizontal" } else { "vertical" });
if explicit_ids {
params["ids"] = json!([ids[2], ids[0], ids[3], ids[1]]);
}
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let mut expected = (*before).clone();
for (i, &id) in ids.iter().enumerate() {
if !locks[i] {
let delta = positions[i] - STARTS[i];
let translation = if horizontal { (delta, 0.0) } else { (0.0, delta) };
let item = expected.item_mut(ItemId(id)).unwrap();
item.xf = Affine::translate(translation) * item.xf;
}
}
s.execute("object.distribute", &params).unwrap();
let after = s.doc().unwrap().doc.clone();
for (i, &id) in ids.iter().enumerate() {
let item = after.item(ItemId(id)).unwrap();
let b = item.bounds();
let start = if horizontal { b.x0 } else { b.y0 };
assert_eq!(start, if locks[i] { STARTS[i] } else { positions[i] }, "item {i}: {params}, locks={locks:?}");
if locks[i] {
assert_eq!(item, before.item(ItemId(id)).unwrap(), "locked item's transform, path, content and flags are unchanged");
}
}
// Only the specified translations may change: all paths, sizes, other-axis coordinates,
// paint, content, stories, layer membership, item order and embedded asset bytes are exact.
assert_eq!(*after, expected, "{params}, locks={locks:?}");
assert_eq!(s.doc().unwrap().selection, selection);
after.check().unwrap();
if expected != *before {
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before));
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &after));
assert_eq!(s.doc().unwrap().selection, selection);
}
}
#[test]
fn distribute_preserves_locked_items_and_moves_unlocked_controls() {
for horizontal in [true, false] {
for (params, positions) in modes() {
// Locked interior object still participates in the numeric reference calculation.
check_distribution(horizontal, params.clone(), positions, [false, true, false, false], false);
// Locked endpoints and an interior object; only the unlocked interior control moves.
check_distribution(horizontal, params, positions, [true, true, false, true], true);
}
}
}
#[test]
fn distribute_unlocked_spacing_modes_are_unchanged() {
for horizontal in [true, false] {
for (params, positions) in modes() {
check_distribution(horizontal, params, positions, [false; 4], true);
}
}
}
#[test]
fn distribute_all_locked_leaves_document_and_selection_unchanged() {
for horizontal in [true, false] {
for (params, positions) in modes() {
check_distribution(horizontal, params, positions, [true; 4], false);
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Effective raster resolution follows image, frame and ancestor transforms in spread space.
use std::sync::Arc;
use designcraft_doc::{Content, Intent, ItemId};
use designcraft_engine::{Session, cmd::base64_encode};
use designcraft_geom::Affine;
use serde_json::{Value, json};
fn place(s: &mut Session, name: &str, bytes: &[u8]) -> ItemId {
s.execute("selection.set", &json!({"ids": []})).unwrap();
let placed = s.execute("file.place", &json!({"base64": base64_encode(bytes), "name": name, "x": 0, "y": 0, "width": 144})).unwrap();
ItemId(placed["id"].as_u64().unwrap())
}
fn raster() -> (Session, ItemId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 1000, "height": 1000, "facingPages": false})).unwrap();
// 600 × 300 pixels at 144 × 72 points: 300 ppi in both directions.
let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png();
let id = place(&mut s, "photo.png", &png);
assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0));
(s, id)
}
fn facing_raster() -> (Session, ItemId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "pages": 3, "facingPages": true})).unwrap();
// Page 0 is the cover; spread 1 contains absolute pages 1 (left) and 2 (right).
let d = &s.doc().unwrap().doc;
assert_eq!(d.spreads.len(), 2);
assert_eq!(d.spreads[0].pages.len(), 1);
assert_eq!(d.spreads[1].pages.len(), 2);
assert_eq!(d.first_page_of_spread(1), 1);
assert_eq!(d.spreads[1].pages[0].x, 0.0);
assert_eq!(d.spreads[1].pages[1].x, 612.0);
let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png();
let placed = s
.execute(
"file.place",
&json!({
"base64": base64_encode(&png), "name": "photo.png", "spread": 1,
"x": 0, "y": 0, "width": 144
}),
)
.unwrap();
let id = ItemId(placed["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(d.find(id).unwrap().spread, designcraft_doc::SpreadRef::Doc(1));
assert_eq!(d.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0));
(s, id)
}
fn transform(s: &mut Session, id: ItemId, frame: Affine, image: Affine) {
s.edit(|d, _| {
let it = d.item_mut(id).unwrap();
it.xf = frame;
let Content::Graphic(g) = &mut it.content else { panic!("graphic fixture") };
g.xf = image * g.xf;
Ok(())
})
.unwrap();
}
fn group(s: &mut Session, child: ItemId, xf: Affine) -> ItemId {
let spread = s.doc().unwrap().doc.find(child).unwrap().spread;
let marker = s.execute("frame.create", &json!({"spread": spread, "rect": [0, 0, 1, 1]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [child.0, marker]})).unwrap();
let id = ItemId(group["id"].as_u64().unwrap());
s.edit(|d, _| {
d.item_mut(id).unwrap().xf = xf;
Ok(())
})
.unwrap();
id
}
fn resolutions(s: &mut Session, params: Value) -> Vec<Value> {
let before = s.doc().unwrap();
let doc = before.doc.clone();
let selection = before.selection.clone();
let state = (before.revision, before.history.undo.len(), before.history.redo.len(), s.journal.len());
let result = s.execute("preflight.run", &params).unwrap();
let after = s.doc().unwrap();
assert!(Arc::ptr_eq(&doc, &after.doc), "preflight must leave the document and assets untouched");
assert_eq!(selection, after.selection, "preflight must leave the selection untouched");
assert_eq!(state, (after.revision, after.history.undo.len(), after.history.redo.len(), s.journal.len()));
result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "lowResolution").cloned().collect()
}
fn warning(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32) {
warning_on_page(s, id, params, ppi, threshold, 0);
}
fn warning_on_page(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32, page: usize) {
assert_eq!(
resolutions(s, params),
vec![json!({
"severity": "warning", "kind": "lowResolution", "item": id.0, "page": page,
"message": format!("photo.png: effective {ppi} ppi (< {threshold})")
})]
);
}
#[test]
fn either_raster_axis_can_fail_after_image_or_frame_scaling() {
for scale in [Affine::scale_non_uniform(4.0, 1.0), Affine::scale_non_uniform(1.0, 4.0)] {
for image_only in [false, true] {
let (mut s, id) = raster();
let (frame, image) = if image_only { (Affine::IDENTITY, scale) } else { (scale, Affine::IDENTITY) };
transform(&mut s, id, frame, image);
// One axis remains 300 ppi, the other is 75 ppi: report the worse axis once.
warning(&mut s, id, json!({}), 75, 150);
}
}
}
#[test]
fn nested_groups_accumulate_scale_and_keep_image_frame_parent_order() {
let (mut s, id) = raster();
let inner = group(&mut s, id, Affine::scale(2.0));
group(&mut s, inner, Affine::scale(2.0));
warning(&mut s, id, json!({}), 75, 150);
let (mut s, id) = raster();
// Double image X, then turn the frame 90 degrees, then triple spread X.
// The image axes become 288 and 216 pt, giving 150 and 100 ppi respectively.
transform(&mut s, id, Affine::new([0.0, 1.0, -1.0, 0.0, 400.0, 20.0]), Affine::scale_non_uniform(2.0, 1.0));
group(&mut s, id, Affine::scale_non_uniform(3.0, 1.0));
warning(&mut s, id, json!({}), 100, 150);
}
#[test]
fn translated_scaled_nested_raster_reports_right_facing_page() {
let (mut s, id) = facing_raster();
let inner = group(&mut s, id, Affine::scale(2.0));
group(&mut s, inner, Affine::translate((640.0, 0.0)) * Affine::scale(2.0));
// Local frame center x=72 is on the left page. Ancestors move its bounds to
// x=640..1216 on the right page, while scaling 300 ppi down to 75 ppi.
warning_on_page(&mut s, id, json!({}), 75, 150, 2);
}
#[test]
fn translating_group_changes_warning_page_without_changing_resolution() {
let (mut s, id) = facing_raster();
// A nonidentity frame transform also detects applying the frame transform twice.
transform(&mut s, id, Affine::translate((-240.0, 0.0)), Affine::IDENTITY);
warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 1);
group(&mut s, id, Affine::translate((880.0, 0.0)));
// Translation moves the center to x=712, but does not change either image axis.
assert!(resolutions(&mut s, json!({})).is_empty());
warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 2);
}
#[test]
fn rotation_translation_and_reflection_do_not_change_resolution() {
for xf in [
Affine::translate((250.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4),
Affine::translate((250.0, 300.0)) * Affine::scale_non_uniform(-1.0, 1.0),
] {
let (mut s, id) = raster();
transform(&mut s, id, xf, Affine::IDENTITY);
// Measuring the rotated bounding box instead would incorrectly warn here.
assert!(resolutions(&mut s, json!({"minPpi": 250})).is_empty());
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
}
}
#[test]
fn cropping_keeps_resolution_and_fitting_changes_it_with_content_scale() {
let (mut s, id) = raster();
s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 144, 72], "to": [0, 0, 36, 18]})).unwrap();
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 36, 18], "to": [0, 0, 144, 216]})).unwrap();
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitProportionally"})).unwrap();
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
// Stretching just Y to 216 pt leaves X at 300 ppi but reduces Y to 100 ppi.
s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitContentToFrame"})).unwrap();
warning(&mut s, id, json!({}), 100, 150);
// Proportional fill is 432 × 216 pt: 100 ppi in both directions.
s.execute("object.fit", &json!({"ids": [id.0], "mode": "fillProportionally"})).unwrap();
warning(&mut s, id, json!({}), 100, 150);
s.execute("object.fit", &json!({"ids": [id.0], "mode": "centerContent"})).unwrap();
warning(&mut s, id, json!({}), 100, 150);
}
#[test]
fn threshold_is_strict_and_resolution_remains_print_only() {
let (mut s, id) = raster();
transform(&mut s, id, Affine::scale_non_uniform(1.0, 2.0), Affine::IDENTITY);
assert!(resolutions(&mut s, json!({})).is_empty(), "150 ppi meets the default threshold");
assert!(resolutions(&mut s, json!({"minPpi": 149})).is_empty());
warning(&mut s, id, json!({"minPpi": 151}), 150, 151);
for intent in [Intent::Web, Intent::Mobile] {
s.edit(|d, _| {
d.settings.intent = intent;
Ok(())
})
.unwrap();
assert!(resolutions(&mut s, json!({"minPpi": 151})).is_empty());
}
}
#[test]
fn transformed_vector_previews_are_excluded_beside_rasters() {
let (mut s, id) = raster();
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="144" height="72"><rect width="144" height="72" fill="red"/></svg>"#;
let vector = place(&mut s, "vector.svg", svg);
let parent = s.execute("object.group", &json!({"ids": [id.0, vector.0]})).unwrap()["id"].as_u64().unwrap();
s.edit(|d, _| {
d.item_mut(ItemId(parent)).unwrap().xf = Affine::scale_non_uniform(1.0, 4.0);
Ok(())
})
.unwrap();
warning(&mut s, id, json!({}), 75, 150);
}
@@ -0,0 +1,34 @@
//! Primary text frames retain all columns supported by the new-document command.
use designcraft_doc::Document;
use designcraft_engine::Session;
use serde_json::json;
fn assert_primary_columns(doc: &Document, expected: u32) {
let mut frames = 0;
for story in doc.stories.values() {
for spec in designcraft_compose::frame_specs(doc, story.id) {
assert_eq!(spec.opts.columns, expected);
let columns = spec.columns();
assert_eq!(columns.len(), expected as usize);
assert!(columns.iter().all(|r| r.width() > 0. && [r.x0, r.x1, r.y0, r.y1].into_iter().all(f64::is_finite)));
frames += 1;
}
}
assert!(frames > 0, "the primary text frame must exist");
}
#[test]
fn primary_columns_survive_composition_native_reopen_and_idml_round_trip() {
for columns in [41, 216] {
let mut s = Session::new();
s.execute("file.new", &json!({"columns":columns,"gutter":0,"primaryTextFrame":true})).unwrap();
assert_primary_columns(&s.doc().unwrap().doc, columns);
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name":"primary-columns.designcraft","base64":bytes["base64"]})).unwrap();
assert_primary_columns(&reopened.doc().unwrap().doc, columns);
let idml = designcraft_idml::export_idml(&reopened.doc().unwrap().doc);
let imported = designcraft_idml::import_idml(&idml).unwrap();
assert_primary_columns(&imported, columns);
}
}
@@ -0,0 +1,68 @@
#![cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
use designcraft_engine::Session;
use serde_json::json;
fn linked_document(root: &Path) -> (Session, std::path::PathBuf) {
let source = root.join("source/contacts.csv");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "Name\nAda\n").unwrap();
let original = root.join("original/template.designcraft");
std::fs::create_dir_all(original.parent().unwrap()).unwrap();
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
session.execute("data.source.select", &json!({"path": source})).unwrap();
session.execute("file.saveAs", &json!({"path": original})).unwrap();
(session, original)
}
#[test]
fn failed_save_as_preserves_document_saved_state_history_and_original_file() {
let temp = tempfile::tempdir().unwrap();
let (mut session, original) = linked_document(temp.path());
session.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
let before = session.doc().unwrap().clone();
let journal = session.journal.clone();
let original_bytes = std::fs::read(&original).unwrap();
let destination = temp.path().join("missing/elsewhere/template.designcraft");
assert!(session.execute("file.saveAs", &json!({"path": destination})).is_err());
let after = session.doc().unwrap();
assert!(Arc::ptr_eq(&before.doc, &after.doc), "failed Save As must not change data-source paths");
assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc));
assert_eq!(before.path, after.path);
assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty()));
assert_eq!(before.doc.data_merge, after.doc.data_merge);
assert_eq!(before.history.undo.len(), after.history.undo.len());
assert_eq!(before.history.redo.len(), after.history.redo.len());
assert_eq!(serde_json::to_value(&before.selection).unwrap(), serde_json::to_value(&after.selection).unwrap());
assert_eq!(journal, session.journal);
assert_eq!(original_bytes, std::fs::read(original).unwrap());
assert!(!destination.exists());
}
#[test]
fn save_a_copy_rebases_its_data_source_without_changing_the_open_document() {
let temp = tempfile::tempdir().unwrap();
let (mut session, original) = linked_document(temp.path());
let before = session.doc().unwrap().clone();
let original_bytes = std::fs::read(&original).unwrap();
let destination = temp.path().join("copies/nested/template.designcraft");
std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
session.execute("file.saveACopy", &json!({"path": destination})).unwrap();
let copy = designcraft_format::load(&std::fs::read(destination).unwrap()).unwrap();
assert_eq!(copy.data_merge.sources[0].relative_path.as_deref(), Some("../../source/contacts.csv"));
let after = session.doc().unwrap();
assert!(Arc::ptr_eq(&before.doc, &after.doc));
assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc));
assert_eq!(before.path, after.path);
assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty()));
assert_eq!(original_bytes, std::fs::read(original).unwrap());
}
+39
View File
@@ -163,3 +163,42 @@ fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() {
}
}
}
/// An underline is one bar per stretch of text: a bar per glyph left lighter seams between the
/// letters where the anti-aliased edges met (at fractional positions).
#[test]
fn an_underline_has_no_seams_between_letters() {
let mut d = Document::new(&NewDocument { width: 400.0, height: 120.0, facing_pages: false, ..Default::default() });
let chars = CharAttrs {
font_family: Some("Source Sans 3".into()),
size: Some(48.0),
fill: Some("[Black]".into()),
underline: Some(true),
underline_weight: Some(Some(6.0)),
..Default::default()
};
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 390.0, 110.0), d.default_layer(), "FERRUM", ParaFormat { chars, ..Default::default() })
.unwrap();
let mut renderer = Renderer::new();
renderer.threads = 0;
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() };
// A fractional scale puts the letters' edges between pixels.
let (w, h) = (543u32, 163u32);
let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options);
let px = |x: u32, y: u32| {
let i = ((y * w + x) * 4) as usize;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]]
};
// The underline's middle row: the row with the longest run of dark pixels ([Black] shows as
// about 52 grey), then nothing lighter than its darkest pixel along it.
let row = (0..h).max_by_key(|&y| (0..w).filter(|&x| px(x, y)[0] < 110).count()).unwrap();
let dark: Vec<u32> = (0..w).filter(|&x| px(x, row)[0] < 110).collect();
let (x0, x1) = (dark[0], *dark.last().unwrap());
assert!(x1 - x0 > 150, "the underline spans the word: {x0}..{x1}");
// The bar's own anti-aliased ends aside.
let inside = x0 + 2..=x1 - 2;
let ink = inside.clone().map(|x| px(x, row)[0]).min().unwrap();
let light: Vec<(u32, u8)> = inside.map(|x| (x, px(x, row)[0])).filter(|&(_, v)| v > ink + 12).collect();
assert!(light.is_empty(), "seams in the underline at {light:?}");
}
@@ -0,0 +1,277 @@
use designcraft_doc::{ParaFormat, Story, StoryId};
use designcraft_engine::Session;
use serde_json::{Value, json};
use std::sync::Arc;
fn setup() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s
}
fn frame(s: &mut Session, text: &str) -> (u64, u64) {
let r = s.execute("frame.create", &json!({"rect":[72,72,300,300],"content":"text","text":text,"caret":false})).unwrap();
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
}
fn snapshot(s: &Session) -> Value {
let d = s.doc().unwrap();
json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":d.history.undo.len(),"redo":d.history.redo.len(),"journal":s.journal})
}
#[test]
fn rejected_thread_is_atomic_with_new_frame_and_redo() {
let mut s = setup();
let (a, _) = frame(&mut s, "hello");
frame(&mut s, "undo");
s.execute("edit.undo", &json!({})).unwrap();
let before = snapshot(&s);
assert!(s.execute("text.thread", &json!({"from":999999,"rect":[0,0,100,100]})).is_err());
assert_eq!(snapshot(&s), before);
assert!(s.execute("text.thread", &json!({"from":a,"rect":[0,0,100,100],"spread":999})).is_err());
assert_eq!(snapshot(&s), before);
}
#[test]
fn locked_frame_cannot_be_unthreaded() {
let mut s = setup();
let (a, _) = frame(&mut s, "hello");
let (b, _) = frame(&mut s, "");
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
s.execute("object.lock", &json!({"ids":[a]})).unwrap();
let before = snapshot(&s);
let r = s.execute("text.unthread", &json!({"frame":a}));
assert!(r.is_err(), "unthread accepted locked frame: {r:?}");
assert_eq!(snapshot(&s), before);
}
#[test]
fn locked_frame_cannot_be_removed_from_thread() {
let mut s = setup();
let (a, _) = frame(&mut s, "hello");
let (b, _) = frame(&mut s, "");
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
s.execute("object.lock", &json!({"ids":[a]})).unwrap();
let before = snapshot(&s);
let r = s.execute("text.removeFromThread", &json!({"frame":a}));
assert!(r.is_err(), "remove accepted locked frame: {r:?}");
assert_eq!(snapshot(&s), before);
}
#[test]
fn rejected_selection_preserves_pending_typing() {
let mut s = setup();
let (_, sid) = frame(&mut s, "hello");
s.execute("text.select", &json!({"story":sid,"anchor":3})).unwrap();
s.execute("type.char", &json!({"attrs":{"size":30}})).unwrap();
let before = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert!(s.execute("text.select", &json!({"story":999999,"anchor":0})).is_err());
let after = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(after, before, "rejected selection reset pending typing");
}
#[test]
fn append_max_id_preserves_both_tables() {
use designcraft_doc::table::Table;
let mut a = Story::with_text(StoryId(1), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
let mut b = Story::with_text(StoryId(2), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
let mut ta = Table::new(u64::MAX, 1, 1, 0, 0, 100.);
ta.cells[0].text = Story::with_text(StoryId(0), "ORIGINAL", ParaFormat::default());
a.tables.insert(u64::MAX, Arc::new(ta));
let mut tb = Table::new(1, 1, 1, 0, 0, 100.);
tb.cells[0].text = Story::with_text(StoryId(0), "INCOMING", ParaFormat::default());
b.tables.insert(1, Arc::new(tb));
a.paras[0].table = Some(u64::MAX);
b.paras[0].table = Some(1);
a.check().unwrap();
b.check().unwrap();
a.append_story(b);
let contents: Vec<_> = a.tables.values().map(|t| t.cells[0].text.text.clone()).collect();
assert!(contents.contains(&"ORIGINAL".into()) && contents.contains(&"INCOMING".into()), "table loss: {contents:?}");
}
#[test]
fn idml_dropcap_detail_preserves_inherited_other_bit() {
use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, build::NewDocument};
let mut d = Document::new(&NewDocument::default());
let parent = ParagraphStyle {
name: "Parent".into(),
based_on: None,
next_style: None,
para: ParaAttrs { drop_cap_align_left: Some(false), drop_cap_scale_descenders: Some(true), ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
};
let child = ParagraphStyle {
name: "Child".into(),
based_on: Some("Parent".into()),
next_style: None,
para: ParaAttrs { drop_cap_scale_descenders: Some(false), ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
};
d.styles_mut().paragraph.extend([parent, child]);
let before = d.styles.resolve_para_style("Child").0;
let bytes = designcraft_idml::export_idml(&d);
let back = designcraft_idml::import_idml(&bytes).unwrap();
let after = back.styles.resolve_para_style("Child").0;
assert_eq!(
(after.drop_cap_align_left, after.drop_cap_scale_descenders),
(before.drop_cap_align_left, before.drop_cap_scale_descenders),
"IDML export resets inherited align-left bit"
);
}
#[test]
fn threading_preserves_incoming_text_hyperlink() {
let mut s = setup();
let (a, sa) = frame(&mut s, "First");
let (b, sb) = frame(&mut s, "Second");
s.execute("text.select", &json!({"story":sb,"anchor":0,"focus":6})).unwrap();
s.execute("hyperlink.create", &json!({"url":"https://example.com"})).unwrap();
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
let d = &s.doc().unwrap().doc;
let back = designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap();
match &back.hyperlinks[0].source {
designcraft_doc::HyperlinkSource::Text { story, start, end } => {
assert_eq!(story.0, sa, "hyperlink points to deleted incoming story");
assert_eq!((*start, *end), (6, 12));
}
_ => panic!("wrong source"),
};
}
#[test]
fn thread_cannot_destroy_body_by_joining_generated_endnotes() {
let mut s = setup();
let (body, sid) = frame(&mut s, "KEEP THIS BODY");
s.execute("text.select", &json!({"story":sid,"anchor":14})).unwrap();
let e = s.execute("endnote.insert", &json!({"text":"a note"})).unwrap();
let ef = e["frame"].as_u64().unwrap();
let before = snapshot(&s);
let r = s.execute("text.thread", &json!({"from":ef,"to":body}));
assert!(
r.is_err(),
"thread into generated endnotes accepted: {r:?}; stories: {:?}",
s.doc().unwrap().doc.stories.values().map(|s| &s.text).collect::<Vec<_>>()
);
assert_eq!(snapshot(&s), before);
}
#[test]
fn generated_endnotes_accept_empty_extensions_but_reject_reverse_body_merge() {
let mut s = setup();
let (body, sid) = frame(&mut s, "Body");
s.execute("text.select", &json!({"story":sid,"anchor":4})).unwrap();
let r = s.execute("endnote.insert", &json!({"text":"note"})).unwrap();
let ef = r["frame"].as_u64().unwrap();
let esid = s.doc().unwrap().doc.endnote_story.unwrap();
let before = snapshot(&s);
assert!(s.execute("text.thread", &json!({"from":body,"to":ef})).is_err());
assert_eq!(snapshot(&s), before);
s.execute("text.thread", &json!({"from":ef,"rect":[72,72,300,300]})).unwrap();
assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid));
s.execute("text.thread", &json!({"to":ef,"rect":[72,72,300,300]})).unwrap();
assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid));
assert_eq!(s.doc().unwrap().doc.story(esid).unwrap().frames.len(), 3);
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text.starts_with("Body"));
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn threading_moves_data_placeholders_and_primary_story_with_text() {
use designcraft_doc::{Placeholder, PlaceholderAnchor, PlaceholderRole};
let mut s = setup();
let (a, sa) = frame(&mut s, "First");
let (b, sb) = frame(&mut s, "<<name>>");
s.edit(|d, _| {
d.settings.primary_story = Some(StoryId(sb));
d.data_merge.placeholders.push(Placeholder {
id: 123,
source_id: 0,
field: "name".into(),
role: PlaceholderRole::Text,
anchor: PlaceholderAnchor::Text { story: StoryId(sb), start: 0, end: 8 },
});
Ok(())
})
.unwrap();
let before = s.doc().unwrap().doc.clone();
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.settings.primary_story, Some(StoryId(sa)));
assert_eq!(d.data_merge.placeholders.len(), 1);
assert_eq!(d.data_merge.placeholders[0].anchor, PlaceholderAnchor::Text { story: StoryId(sa), start: 6, end: 14 });
let after = d.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
}
#[test]
fn append_max_ids_preserves_addressable_footnotes_and_endnotes() {
let mut a = Story::with_text(StoryId(1), "A", ParaFormat::default());
let mut b = Story::with_text(StoryId(2), "B", ParaFormat::default());
a.insert_note(1, "original footnote", ParaFormat::default());
b.insert_note(1, "incoming footnote", ParaFormat::default());
Arc::make_mut(&mut a.notes[0]).id = u64::MAX;
a.insert_endnote(1, "original endnote", ParaFormat::default());
b.insert_endnote(1, "incoming endnote", ParaFormat::default());
Arc::make_mut(&mut a.endnotes[0]).id = u64::MAX;
a.check().unwrap();
b.check().unwrap();
a.append_story(b);
a.check().unwrap();
assert_ne!(a.notes[0].id, a.notes[1].id);
assert_ne!(a.endnotes[0].id, a.endnotes[1].id);
assert_eq!(a.notes[0].text.text, "original footnote");
assert_eq!(a.endnotes[1].text.text, "incoming endnote");
}
#[test]
fn locked_layer_and_neighbour_reject_unthread_atomically() {
for lock_layer in [false, true] {
let mut s = setup();
let (a, _) = frame(&mut s, "A");
let (b, _) = frame(&mut s, "B");
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
if lock_layer {
let lid = s.doc().unwrap().active_layer.0;
s.execute("layer.set", &json!({"id":lid,"locked":true})).unwrap();
} else {
s.execute("object.lock", &json!({"ids":[b]})).unwrap();
}
let before = snapshot(&s);
assert!(s.execute("text.unthread", &json!({"frame":a})).is_err());
assert_eq!(snapshot(&s), before);
}
}
#[test]
fn idml_partial_dropcap_bits_resolve_both_style_and_paragraph_inheritance() {
use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, SpreadRef, build::NewDocument};
use designcraft_geom::Rect;
for parent_align in [false, true] {
for parent_scale in [false, true] {
for local_align in [false, true] {
let mut d = Document::new(&NewDocument::default());
d.styles_mut().paragraph.push(ParagraphStyle {
name: "Parent".into(),
based_on: None,
next_style: None,
para: ParaAttrs { drop_cap_align_left: Some(parent_align), drop_cap_scale_descenders: Some(parent_scale), ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
});
let layer = d.default_layer();
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(10., 10., 200., 200.),
layer,
"Body",
ParaFormat {
style: "Parent".into(),
para: ParaAttrs { drop_cap_align_left: Some(local_align), ..Default::default() },
..Default::default()
},
)
.unwrap();
let bytes = designcraft_idml::export_idml(&d);
let back = designcraft_idml::import_idml(&bytes).unwrap();
let st = back.stories.values().find(|s| s.text == "Body").unwrap();
let actual = back.styles.resolve_para(&st.paras[0]).0;
let expected = d.styles.resolve_para(&d.story(sid).unwrap().paras[0]).0;
assert_eq!(
(actual.drop_cap_align_left, actual.drop_cap_scale_descenders),
(expected.drop_cap_align_left, expected.drop_cap_scale_descenders)
);
}
}
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Linked-story threading and text-color transaction safety.
use designcraft_doc::{ParaFormat, Story, StoryId, table::Table};
use designcraft_engine::Session;
use serde_json::{Value, json};
use std::sync::Arc;
fn setup() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s
}
fn frame(s: &mut Session, text: &str) -> (u64, StoryId) {
let f = s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":text,"caret":false})).unwrap();
(f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap()))
}
fn child(s: &mut Session, parent: StoryId) -> (u64, StoryId) {
let f = s.execute("story.placeAndLink", &json!({"story":parent.0,"rect":[36,150,200,100]})).unwrap();
(f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap()))
}
fn state(s: &Session) -> Value {
let d = s.doc().unwrap();
json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":format!("{:?}",d.history),"journal":s.journal})
}
fn link(s: &mut Session, sid: StoryId) -> Value {
s.execute("story.links", &json!({})).unwrap().as_array().unwrap().iter().find(|l| l["story"] == sid.0).unwrap().clone()
}
#[test]
fn safety_linked_parent_follows_merge_and_updates_after_native_reopen() {
let mut s = setup();
let (first, survivor) = frame(&mut s, "PREFIX");
let (incoming, parent) = frame(&mut s, "PARENT");
let (_, linked) = child(&mut s, parent);
// A coincident old parent revision must not hide the change of parent identity/content.
let future_rev = s.doc().unwrap().doc.story(survivor).unwrap().rev + 2;
s.edit(|d, _| {
d.story_mut(linked).unwrap().link = Some((parent, future_rev));
Ok(())
})
.unwrap();
s.execute("text.thread", &json!({"from":first,"to":incoming})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
assert_eq!(link(&mut s, linked)["outOfDate"], true);
assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "PARENT");
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name":"links.designcraft","base64":bytes["base64"]})).unwrap();
assert_eq!(link(&mut reopened, linked)["parent"], survivor.0);
reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap();
assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "PREFIX\nPARENT");
assert_eq!(link(&mut reopened, linked)["outOfDate"], false);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], parent.0);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
}
#[test]
fn safety_linked_child_cannot_merge_body_text_in_either_direction() {
for reverse in [false, true] {
let mut s = setup();
let (_, parent) = frame(&mut s, "SOURCE");
let (linked, child) = child(&mut s, parent);
let (body, _) = frame(&mut s, "BODY");
let (from, to) = if reverse { (body, linked) } else { (linked, body) };
let before = state(&s);
let error = s.execute("text.thread", &json!({"from":from,"to":to})).unwrap_err();
assert!(error.to_string().contains("linked story"));
assert_eq!(state(&s), before);
s.execute("story.updateLink", &json!({"story":child.0})).unwrap();
assert!(s.doc().unwrap().doc.stories.values().any(|st| st.text == "BODY"));
}
}
#[test]
fn safety_linked_child_allows_empty_extensions_in_both_directions() {
for reverse in [false, true] {
let mut s = setup();
let (_, parent) = frame(&mut s, "SOURCE");
let (linked, child) = child(&mut s, parent);
let (empty, _) = frame(&mut s, "");
let (from, to) = if reverse { (empty, linked) } else { (linked, empty) };
s.execute("text.thread", &json!({"from":from,"to":to})).unwrap();
assert_eq!(link(&mut s, child)["parent"], parent.0);
s.execute("story.setText", &json!({"story":parent.0,"text":"UPDATED"})).unwrap();
assert_eq!(link(&mut s, child)["outOfDate"], true);
s.execute("story.updateLink", &json!({"story":child.0})).unwrap();
let st = s.doc().unwrap().doc.story(child).unwrap();
assert_eq!(st.text, "UPDATED");
assert_eq!(st.frames.len(), 2);
assert_eq!(link(&mut s, child)["outOfDate"], false);
}
}
#[test]
fn safety_linked_empty_parent_cannot_be_merged_into_its_child() {
let mut s = setup();
let (parent_frame, parent) = frame(&mut s, "");
let (linked, _) = child(&mut s, parent);
let before = state(&s);
assert!(s.execute("text.thread", &json!({"from":parent_frame,"to":linked})).is_err());
assert_eq!(state(&s), before);
}
#[test]
fn safety_text_color_without_target_is_atomic_and_carets_remain_valid() {
let mut s = setup();
let f = s.execute("frame.create", &json!({"rect":[36,36,200,100]})).unwrap();
s.execute("selection.set", &json!({"ids":[f["id"]]})).unwrap();
let before = state(&s);
assert!(s.execute("object.color", &json!({"color":"#123456","text":true})).is_err());
assert_eq!(state(&s), before);
for text in ["", "Words"] {
let (_, sid) = frame(&mut s, text);
s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap();
let color = s.execute("object.color", &json!({"color":"#654321","text":true})).unwrap();
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.fill.as_deref(), color["swatch"].as_str());
}
}
fn nested_story(depth: usize, name: &str) -> Story {
let mut st = Story::with_text(StoryId(0), "leaf", ParaFormat::default());
st.chars[0].format.over.fill = Some(name.into());
for _ in 0..depth {
let mut outer = Story::with_text(StoryId(0), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
let mut t = Table::new(1, 1, 1, 0, 0, 100.);
t.cells[0].text = st;
outer.tables.insert(1, Arc::new(t));
outer.paras[0].table = Some(1);
st = outer;
}
st
}
#[test]
fn safety_deep_swatch_rename_rejects_atomically_with_pending_color() {
// A small boundary probe, never an attempted stack-overflow reproduction.
for depth in [33, 34] {
let mut s = setup();
let (_, sid) = frame(&mut s, "root");
s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap();
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
s.edit(|d, _| {
let st = d.story_mut(sid).unwrap();
let frames = st.frames.clone();
*st = nested_story(depth, &old);
st.id = sid;
st.frames = frames;
Ok(())
})
.unwrap();
let pending = s.execute("type.selectionAttrs", &json!({})).unwrap();
let before = state(&s);
let doc = s.doc().unwrap().doc.clone();
let error = s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap_err();
assert!(error.to_string().contains("nesting"));
assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
assert_eq!(state(&s), before);
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap(), pending);
}
}
#[test]
fn safety_swatch_rename_reaches_last_supported_depth() {
let mut s = setup();
let (_, sid) = frame(&mut s, "root");
s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap();
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
s.edit(|d, _| {
let st = d.story_mut(sid).unwrap();
let frames = st.frames.clone();
*st = nested_story(32, &old);
st.id = sid;
st.frames = frames;
Ok(())
})
.unwrap();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
let mut st = s.doc().unwrap().doc.story(sid).unwrap();
for _ in 0..32 {
st = &st.tables[&1].cells[0].text;
}
assert_eq!(st.format_after(0).over.fill.as_deref(), Some("Brand"));
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Brand");
}
#[test]
fn safety_empty_linked_parent_follows_survivor_only_on_explicit_update() {
for incoming_empty in [false, true] {
for alternate_layout in [false, true] {
let mut s = setup();
let (empty, parent) = frame(&mut s, "");
let linked = if alternate_layout {
s.execute("layout.createAlternate", &json!({"name":"Alternate","width":600,"height":800})).unwrap();
let links = s.execute("story.links", &json!({})).unwrap();
StoryId(links.as_array().unwrap().iter().find(|l| l["parent"] == parent.0).unwrap()["story"].as_u64().unwrap())
} else {
child(&mut s, parent).1
};
let (body, survivor) = frame(&mut s, "BODY");
let (from, to) = if incoming_empty { (body, empty) } else { (empty, body) };
s.execute("text.thread", &json!({"from":from,"to":to})).unwrap();
assert!(s.doc().unwrap().doc.story(parent).is_none());
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
assert_eq!(link(&mut s, linked)["outOfDate"], true);
assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], parent.0);
assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name":"empty-parent.designcraft","base64":bytes["base64"]})).unwrap();
assert_eq!(link(&mut reopened, linked)["outOfDate"], true);
assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "");
reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap();
assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "BODY");
assert_eq!(link(&mut reopened, linked)["outOfDate"], false);
}
}
}
+46 -16
View File
@@ -322,7 +322,10 @@ pub fn stylesheet(doc: &Document) -> String {
let mut rule = String::new();
para_css(&pp, base, lead, &mut rule);
char_css(doc, &cp, base, &mut rule);
let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]);
let (tag, class) = export_mapping(st, &ps.name, false, &pc);
if let Some(selector) = class_selector(tag, &class) {
let _ = writeln!(css, "{selector} {{ {rule}}}");
}
}
for cs in &st.character {
if cs.name == story::NO_CHAR_STYLE {
@@ -347,7 +350,10 @@ pub fn stylesheet(doc: &Document) -> String {
if let Some(sz) = c.size {
let _ = write!(rule, "font-size: {:.3}em; ", sz / base);
}
let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]);
let (tag, class) = export_mapping(st, &cs.name, true, &cc);
if let Some(selector) = class_selector(tag, &class) {
let _ = writeln!(css, "{selector} {{ {rule}}}");
}
}
css
}
@@ -386,6 +392,39 @@ fn is_tag(t: &str, character: bool) -> bool {
}
}
/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated,
/// collision-free class when no custom class was supplied, and the existing tag fallback.
fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) {
let et = st.export_tag(name, character);
let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" });
let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name));
// Match the characters xml_attr actually emits, before escaping either output context.
let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
(tag, class)
}
/// Each class token must match on the same element. Escape CSS identifier punctuation and
/// leading digits without changing the class written into XHTML.
fn class_selector(tag: &str, class: &str) -> Option<String> {
let mut tokens = class.split_ascii_whitespace().peekable();
// A nonempty but whitespace-only custom class has no token to select. Never emit a bare
// element selector, which would apply this style to unrelated paragraphs or character runs.
tokens.peek()?;
let mut selector = tag.to_string();
for token in tokens {
selector.push('.');
for (i, c) in token.chars().enumerate() {
let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-'));
if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) {
selector.push(c);
} else {
let _ = write!(selector, "\\{:x} ", c as u32);
}
}
}
Some(selector)
}
/// One story as XHTML paragraphs.
pub fn story_html(doc: &Document, sid: StoryId) -> String {
let Some(st) = doc.story(sid) else { return String::new() };
@@ -394,10 +433,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
for (pi, r) in st.para_ranges().iter().enumerate() {
let pf = &st.paras[pi];
// Export Tagging: the style's element and class.
let et = doc.styles.export_tag(&pf.style, false);
let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p");
let class =
et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style));
let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc);
let text_empty = st.text[r.clone()].trim().is_empty();
let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft;
let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" });
@@ -427,17 +463,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
} else {
story_markup(raw)
};
let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten();
let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span");
let cls = if f.style != story::NO_CHAR_STYLE {
let class = et
.map(|e| e.class.clone())
.filter(|c| !c.is_empty())
.or_else(|| cc.get(f.style.as_str()).cloned())
.unwrap_or_else(|| slug(&f.style));
format!(" class=\"{}\"", xml_attr(&class))
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc);
(tag, format!(" class=\"{}\"", xml_attr(&class)))
} else {
String::new()
("span", String::new())
};
let style = override_css(doc, &f.over);
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };
+138
View File
@@ -0,0 +1,138 @@
use std::io::{Cursor, Read};
use designcraft_doc::build::NewDocument;
use designcraft_doc::geom::Rect;
use designcraft_doc::{Align, CharAttrs, CharacterStyle, Document, ExportTag, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, story};
use designcraft_epub::{EpubOptions, HtmlOptions, export_epub, export_html};
fn fixture() -> Document {
let mut doc = Document::new(&NewDocument::default());
let styles = doc.styles_mut();
styles.paragraph.iter_mut().find(|p| p.name == story::BASIC_PARAGRAPH).unwrap().chars.size = Some(12.0);
styles.paragraph.push(ParagraphStyle {
name: "Chapter Title".into(),
based_on: None,
next_style: None,
para: ParaAttrs { align: Some(Align::Center), ..Default::default() },
chars: CharAttrs { size: Some(24.0), ..Default::default() },
shortcut: String::new(),
});
styles.character.push(CharacterStyle {
name: "Key Term".into(),
based_on: None,
chars: CharAttrs { size: Some(18.0), ..Default::default() },
shortcut: String::new(),
});
let layer = doc.default_layer();
doc.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 36.0, 300.0, 100.0),
layer,
"Heading",
ParaFormat { style: "Chapter Title".into(), ..Default::default() },
)
.unwrap();
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 120.0, 300.0, 200.0), layer, "Accent plain", ParaFormat::default()).unwrap();
doc.story_mut(sid).unwrap().format_chars(0..6, |f| f.style = "Key Term".into());
doc
}
fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) {
let tags = &mut doc.styles_mut().export_tags;
tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() });
tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() });
}
fn package(doc: &Document) -> (String, String) {
let mut zip = zip::ZipArchive::new(Cursor::new(export_epub(doc, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut text = String::new();
zip.by_name(path).unwrap().read_to_string(&mut text).unwrap();
text
};
let xhtml = read("OEBPS/content.xhtml");
let css = read("OEBPS/style.css");
let opf = read("OEBPS/content.opf");
assert!(xhtml.contains("<link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/>"));
assert!(opf.contains("href=\"style.css\" media-type=\"text/css\""));
(xhtml, css)
}
fn rule<'a>(css: &'a str, selector: &str) -> &'a str {
css.lines().find_map(|line| line.strip_prefix(&format!("{selector} {{ "))).unwrap_or_else(|| panic!("missing {selector} in {css}"))
}
#[test]
fn mapped_tags_and_classes_keep_their_packaged_style_declarations() {
let mut doc = fixture();
map(&mut doc, "h1", "chapter", "em", "term");
let (xhtml, css) = package(&doc);
assert!(xhtml.contains("<h1 class=\"chapter\">Heading</h1>"), "{xhtml}");
assert!(xhtml.contains("<em class=\"term\">Accent</em> plain"), "{xhtml}");
assert!(rule(&css, "h1.chapter").contains("font-size: 2.000em;"));
assert!(rule(&css, "h1.chapter").contains("text-align: center;"));
assert!(rule(&css, "em.term").contains("font-size: 1.500em;"));
assert!(!css.contains("p.chapter-title {") && !css.contains("span.key-term {"));
assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;"));
// HTML uses the same mapped stylesheet inline, while EPUB links its packaged CSS.
let html = export_html(&doc, &HtmlOptions::default());
assert!(html.contains(&css));
assert!(html.contains("<h1 class=\"chapter\">Heading</h1>"));
}
#[test]
fn tag_only_class_only_and_unsupported_tags_keep_existing_fallbacks() {
for (pt, pc, ct, cc, paragraph, character) in [
("h2", "", "em", "", "h2.chapter-title", "em.key-term"),
("", "chapter", "", "term", "p.chapter", "span.term"),
("section", "", "h2", "", "p.chapter-title", "span.key-term"),
("", "", "", "", "p.chapter-title", "span.key-term"),
] {
let mut doc = fixture();
map(&mut doc, pt, pc, ct, cc);
let (xhtml, css) = package(&doc);
for (selector, text, size) in [(paragraph, "Heading", "2.000em"), (character, "Accent", "1.500em")] {
let (tag, class) = selector.split_once('.').unwrap();
assert!(xhtml.contains(&format!("<{tag} class=\"{class}\">{text}</{tag}>")), "{xhtml}");
assert!(rule(&css, selector).contains(&format!("font-size: {size};")));
}
}
let (xhtml, css) = package(&fixture());
assert!(xhtml.contains("<p class=\"chapter-title\">Heading</p>"));
assert!(rule(&css, "p.chapter-title").contains("font-size: 2.000em;"));
assert!(rule(&css, "span.key-term").contains("font-size: 1.500em;"));
}
#[test]
fn custom_class_tokens_match_without_renaming_them() {
for (class, attribute, selector) in [
("lead wide", "lead wide", ".lead.wide"),
("lead\twide\n", "lead\twide\n", ".lead.wide"),
("chapter:lead", "chapter:lead", r".chapter\3a lead"),
("2nd-edition", "2nd-edition", r".\32 nd-edition"),
("-2nd", "-2nd", r".-\32 nd"),
("-", "-", r".\2d "),
("見出し.café", "見出し.café", r".見出し\2e café"),
("edition \"wide\"", "edition &quot;wide&quot;", r".edition.\22 wide\22 "),
] {
let mut doc = fixture();
map(&mut doc, "h2", class, "em", class);
let (xhtml, css) = package(&doc);
assert!(xhtml.contains(&format!("<h2 class=\"{attribute}\">Heading</h2>")), "{xhtml}");
assert!(xhtml.contains(&format!("<em class=\"{attribute}\">Accent</em>")), "{xhtml}");
assert!(rule(&css, &format!("h2{selector}")).contains("font-size: 2.000em;"));
assert!(rule(&css, &format!("em{selector}")).contains("font-size: 1.500em;"));
}
}
#[test]
fn whitespace_only_custom_classes_do_not_apply_rules_to_every_element() {
let mut doc = fixture();
map(&mut doc, "p", " \t", "span", " \n");
let (xhtml, css) = package(&doc);
assert!(xhtml.contains("<p class=\" \t\">Heading</p>"));
assert!(xhtml.contains("<span class=\" \n\">Accent</span>"));
assert!(!css.lines().any(|line| line.starts_with("p {") || line.starts_with("span {")));
assert!(!css.contains("font-size: 2.000em;") && !css.contains("font-size: 1.500em;"));
assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;"));
}
+141 -5
View File
@@ -125,6 +125,8 @@ pub struct FontFace {
em: std::sync::OnceLock<((f64, f64), bool)>,
/// The font menu group and native family name, read on first use.
group: std::sync::OnceLock<(FontGroup, Option<String>)>,
/// Format, class and PANOSE numbers (the font menus), read on first use.
kind: std::sync::OnceLock<crate::kind::FaceKind>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
@@ -192,6 +194,32 @@ impl FontFace {
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
/// The face's format, class and PANOSE numbers, as the font menus show and filter them.
pub fn kind(&self) -> crate::kind::FaceKind {
*self.kind.get_or_init(|| {
let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default();
// Nothing in the tables or the name: look at the letters.
if kind.class == crate::kind::FontClass::Other
&& let Some(serifs) = has_serifs(self)
{
kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif };
}
kind
})
}
/// When the font file was added (created, else last modified): the font menus' Show Recently
/// Added. `None` for fonts without a file.
pub fn added(&self) -> Option<std::time::SystemTime> {
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(self.source.path()?).ok()?;
meta.created().or_else(|_| meta.modified()).ok()
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
@@ -445,6 +473,29 @@ const KOREAN_FALLBACKS: &[&str] = &[
"Source Han Sans K",
];
/// Fonts made for Malayalam (macOS, Windows, Noto, Linux). A font that only maps the characters
/// (Arial Unicode MS, first by path on macOS) sets them without conjuncts or vowel signs.
const MALAYALAM_FALLBACKS: &[&str] = &[
"Malayalam Sangam MN",
"Malayalam MN",
"Nirmala UI",
"Kartika",
"Noto Sans Malayalam",
"Noto Serif Malayalam",
"Manjari",
"Rachana",
"Meera",
"Lohit Malayalam",
];
/// The fallback chain of a script that only fonts made for it set well, whatever the language.
fn script_chain(c: char) -> Option<&'static [&'static str]> {
match c as u32 {
0x0D00..=0x0D7F => Some(MALAYALAM_FALLBACKS),
_ => None,
}
}
/// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order
/// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`].
fn japanese_chain() -> &'static [&'static str] {
@@ -728,6 +779,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
bmp: std::sync::OnceLock::new(),
em: std::sync::OnceLock::new(),
group: std::sync::OnceLock::new(),
kind: std::sync::OnceLock::new(),
})
}
@@ -1399,6 +1451,43 @@ impl ScopedFonts<'_> {
None
}
/// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular
/// face (else its first), for the font menus' filters, which look at every family. A face
/// already loaded is asked; an installed family that isn't loaded is read from its file, which
/// is then let go rather than kept loaded. None for an unknown family or an unreadable file.
pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option<std::time::SystemTime>)> {
let styles = self.styles(family);
let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone();
if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) {
let f = self.find(family, &style)?;
return Some((f.kind(), f.added()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = {
let cat = self.db.read_catalog();
let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family);
let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family));
entry.map(|c| c.path.clone())?
};
if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES {
return None;
}
let data = Arc::new(std::fs::read(&path).ok()?);
let found = enumerate_faces(&data);
let pick = found
.iter()
.find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style))
.or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))?
.clone();
let (index, fam, st, coords) = pick;
let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?;
Some((face.kind(), face.added()))
}
#[cfg(target_arch = "wasm32")]
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 {
@@ -1497,12 +1586,12 @@ impl ScopedFonts<'_> {
}
/// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when
/// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the
/// first face (fallback family first, then load order, the document's fonts last) that covers
/// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face
/// covers a character.
/// unknown). CJK characters try their language's chain first (see [`cjk_chain`]), Malayalam
/// the fonts made for it (see [`script_chain`]); then the first face (fallback family first,
/// then load order, the document's fonts last) that covers `c`. On native, system fonts are
/// cataloged and loaded lazily the first time no loaded face covers a character.
pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option<Arc<FontFace>> {
if let Some(chain) = cjk_chain(c, language)
if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c))
&& let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude))
{
return Some(f);
@@ -1554,6 +1643,53 @@ fn over_total() -> String {
format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20)
}
/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when
/// it has neither letter.
fn has_serifs(face: &FontFace) -> Option<bool> {
let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?;
let path = font_outline(face, gid);
let bbox = kurbo::Shape::bounding_box(&path);
// An empty or degenerate outline (NaN included) says nothing.
if bbox.height().is_nan() || bbox.height() <= 1.0 {
return None;
}
let mut edges = Vec::new();
let mut last = None;
let mut start = None;
kurbo::flatten(&path, 1.0, |el| match el {
kurbo::PathEl::MoveTo(p) => {
last = Some(p);
start = Some(p);
}
kurbo::PathEl::LineTo(p) => {
if let Some(a) = last {
edges.push((a, p));
}
last = Some(p);
}
kurbo::PathEl::ClosePath => {
if let (Some(a), Some(b)) = (last, start) {
edges.push((a, b));
}
last = start;
}
_ => {}
});
// The outline is y-down: the foot is near bbox.y1 (the baseline).
let width_at = |y: f64| {
let xs: Vec<f64> = edges
.iter()
.filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y))
.map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y))
.collect();
let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
(hi > lo).then_some(hi - lo)
};
let foot = width_at(bbox.y1 - bbox.height() * 0.02)?;
let stem = width_at(bbox.y0 + bbox.height() * 0.5)?;
Some(foot > stem * 1.6)
}
/// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped).
fn font_outline(face: &FontFace, gid: u32) -> BezPath {
let mut pen = FlipPen(BezPath::new());
+191
View File
@@ -0,0 +1,191 @@
//! What the font menus say about a face: its file format (the icon after the sample), its
//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts).
use skrifa::raw::TableProvider;
use skrifa::raw::types::Tag;
/// A face's font format, as the font menus show it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FontFormat {
/// TrueType outlines (`glyf`).
TrueType,
/// PostScript (CFF) outlines.
OpenType,
/// A variable font (`fvar`).
Variable,
/// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts.
Color,
}
/// The classes the font menus filter by.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FontClass {
Serif,
SlabSerif,
SansSerif,
Script,
Monospaced,
Decorative,
Symbol,
/// Nothing in the font says.
Other,
}
impl FontClass {
/// The classes, in the Filters menu's order.
pub const ALL: [FontClass; 8] = [
FontClass::Serif,
FontClass::SlabSerif,
FontClass::SansSerif,
FontClass::Script,
FontClass::Monospaced,
FontClass::Decorative,
FontClass::Symbol,
FontClass::Other,
];
/// The name the Filters menu shows (English; the UI translates it).
pub fn label(self) -> &'static str {
match self {
FontClass::Serif => "Serif",
FontClass::SlabSerif => "Slab Serif",
FontClass::SansSerif => "Sans Serif",
FontClass::Script => "Script",
FontClass::Monospaced => "Monospaced",
FontClass::Decorative => "Decorative",
FontClass::Symbol => "Symbol",
FontClass::Other => "Other",
}
}
}
/// A face's format, class and PANOSE numbers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FaceKind {
pub format: FontFormat,
pub class: FontClass,
/// The OS/2 table's PANOSE classification (all zero when the font has none).
pub panose: [u8; 10],
}
impl Default for FaceKind {
fn default() -> Self {
FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] }
}
}
/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say.
pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind {
let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some();
let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) {
FontFormat::Color
} else if has(b"fvar") {
FontFormat::Variable
} else if has(b"CFF ") || has(b"CFF2") {
FontFormat::OpenType
} else {
FontFormat::TrueType
};
let os2 = f.os2().ok();
let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]);
let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8);
let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0);
FaceKind { format, class: classify(&panose, ibm, fixed, family), panose }
}
/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2,
/// else the family name.
pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass {
let name = family.to_lowercase();
let named = |words: &[&str]| words.iter().any(|w| name.contains(w));
// PANOSE proportion 9 is monospaced (Latin text).
if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) {
return FontClass::Monospaced;
}
match panose[0] {
3 => return FontClass::Script,
4 => return FontClass::Decorative,
5 => return FontClass::Symbol,
2 => match panose[1] {
// Square serifs are slabs; cove, bone, triangle… serifs are serifs.
6 => return FontClass::SlabSerif,
2..=10 => return FontClass::Serif,
11..=15 => return FontClass::SansSerif,
_ => {}
},
_ => {}
}
match ibm_class {
1 | 2 | 3 | 7 => FontClass::Serif,
4 | 5 => FontClass::SlabSerif,
8 => FontClass::SansSerif,
9 => FontClass::Decorative,
10 => FontClass::Script,
12 => FontClass::Symbol,
_ if named(&["slab"]) => FontClass::SlabSerif,
_ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif,
_ if named(&["script", "hand", "brush"]) => FontClass::Script,
_ if named(&["serif"]) => FontClass::Serif,
_ => FontClass::Other,
}
}
/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both
/// fonts set; `None` when they share none.
pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option<u32> {
let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect();
if pairs.is_empty() {
return None;
}
Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::<u32>() * 9 / pairs.len() as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classes_come_from_panose_then_the_ibm_class_then_the_name() {
let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0];
assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif);
assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif);
assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif);
assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9");
assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script);
assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8");
assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch");
assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced);
assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif);
assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other);
}
#[test]
fn bundled_fonts_are_classified() {
let db = crate::FontDb::global();
for (family, class) in [
("Source Serif 4", FontClass::Serif),
("Source Sans 3", FontClass::SansSerif),
("Inter", FontClass::SansSerif),
("JetBrains Mono", FontClass::Monospaced),
] {
let kind = db.face(family, "Regular").kind();
assert_eq!(kind.class, class, "{family}");
assert_eq!(kind.format, FontFormat::TrueType, "{family}");
}
let serif = db.face("Source Serif 4", "Regular").kind().panose;
let sans = db.face("Source Sans 3", "Regular").kind().panose;
let serif_bold = db.face("Source Serif 4", "Bold").kind().panose;
if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) {
assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})");
}
}
#[test]
fn panose_distance_counts_shared_digits() {
let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4];
assert_eq!(panose_distance(&a, &a), Some(0));
let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4];
assert!(panose_distance(&a, &b).is_some_and(|d| d > 0));
assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare");
}
}
+2
View File
@@ -9,6 +9,7 @@
mod fontdb;
mod group;
mod kind;
#[cfg(any(test, feature = "testing"))]
pub mod testing;
mod vertical;
@@ -20,6 +21,7 @@ pub use fontdb::{
pub use group::{FamilyInfo, FontGroup, sort_for_menu};
pub use harfrust::Feature;
use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer};
pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance};
pub use kurbo::BezPath;
use skrifa::MetadataProvider;
use skrifa::instance::Size;
+30
View File
@@ -127,3 +127,33 @@ fn chain_fonts_load_from_the_font_folders_when_first_needed() {
assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK")));
assert!(!db.is_loaded("AppleMyungjo"));
}
const KA: char = 'ക';
const VIRAMA: char = '\u{0D4D}';
const AA: char = '\u{0D3E}';
#[test]
fn malayalam_takes_a_font_made_for_it() {
// Arial Unicode MS maps Malayalam but can't shape it (no conjuncts); loaded first, it won.
let db = db_with(&[("Arial Unicode MS", &[KA, VIRAMA, AA]), ("Malayalam Sangam MN", &[KA, VIRAMA, AA])]);
for c in [KA, VIRAMA, AA] {
for language in [None, Some("en-US"), Some("ml")] {
assert_eq!(family(&db, c, language).as_deref(), Some("Malayalam Sangam MN"), "{c:?} {language:?}");
}
}
// Past the Malayalam fonts, the language-blind search as before.
let sangam = db.face("Malayalam Sangam MN", "Regular");
assert_eq!(db.fallback_for(KA, sangam.id(), None).map(|f| f.family.clone()).as_deref(), Some("Arial Unicode MS"));
}
#[test]
fn malayalam_fonts_load_from_the_font_folders_when_first_needed() {
let dir = std::env::temp_dir().join(format!("dc-malayalam-fallback-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("noto.ttf"), font_with("Noto Sans Malayalam", &[KA]).unwrap()).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
db.add_font(font_with("Arial Unicode MS", &[KA]).unwrap());
assert!(!db.is_loaded("Noto Sans Malayalam"));
assert_eq!(family(&db, KA, None).as_deref(), Some("Noto Sans Malayalam"));
}
+17
View File
@@ -126,6 +126,23 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() {
assert_eq!(db.load_system_fonts(), 2);
}
#[test]
fn a_familys_kind_is_read_without_keeping_it_loaded() {
let dir = font_dir("kind");
let db = FontDb::with_font_dirs(vec![dir]);
db.load_system_fonts();
let fonts = db.scoped(0);
let (kind, added) = fonts.family_kind(FAMILY).expect("an installed family");
assert_eq!(kind.class, crate::kind::FontClass::SansSerif);
assert!(added.is_some(), "the file's date");
assert!(!db.is_loaded(FAMILY), "the file was read and let go");
// A loaded family is asked directly, with the same answer.
let _ = fonts.face(FAMILY, "Regular");
assert!(db.is_loaded(FAMILY));
assert_eq!(fonts.family_kind(FAMILY).map(|(k, _)| k.class), Some(crate::kind::FontClass::SansSerif));
assert!(fonts.family_kind("No Such Family").is_none());
}
#[test]
fn the_scan_reads_names_without_loading_the_font() {
let dir = font_dir("names");
+138 -41
View File
@@ -1,9 +1,10 @@
//! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy.
//!
//! Corners are applied at render time to the straight-segment corners of a closed path, so the
//! frame keeps its simple editable geometry (as in InDesign).
//! Corners are applied at render time to the corner points of a closed path (where its direction
//! turns), so the frame keeps its simple editable geometry (as in InDesign). Curved sides stay
//! curved: a corner where an arc meets a line trims the arc along its length.
use kurbo::{BezPath, Point, Vec2};
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2};
use serde::{Deserialize, Serialize};
use crate::path::{PathData, SubPath};
@@ -63,8 +64,9 @@ impl CornerOptions {
}
}
/// Apply corner options to `path`. Corners are applied to anchors joining two straight segments of
/// closed subpaths; other anchors are kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`.
/// Apply corner options to `path`. Corners are applied to the corner points of closed subpaths
/// (where the incoming and outgoing directions differ, curved sides included); smooth points are
/// kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`.
pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath {
if opts.is_none() {
return path.to_bezpath();
@@ -76,50 +78,84 @@ pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath {
out
}
/// Arc length accuracy (points) when trimming curved sides.
const ARC_ACCURACY: f64 = 1e-3;
/// Directions closer than this (cosine, about 1°) are a smooth point, not a corner.
const SMOOTH: f64 = 0.9998;
/// The unit direction a segment leaves its start in (None for a point).
fn start_dir(c: &CubicBez) -> Option<Vec2> {
[c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot())
}
/// The unit direction a segment arrives at its end in (None for a point).
fn end_dir(c: &CubicBez) -> Option<Vec2> {
[c.p2, c.p1, c.p0].into_iter().map(|q| c.p3 - q).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot())
}
/// Does the path turn where `prev` ends and `next` starts?
fn is_corner(prev: &CubicBez, next: &CubicBez) -> bool {
matches!((end_dir(prev), start_dir(next)), (Some(a), Some(b)) if a.dot(b) < SMOOTH)
}
fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) {
let n = sp.anchors.len();
let straight_corner = |i: usize| {
let a = &sp.anchors[i];
sp.closed && !a.has_in() && !a.has_out()
};
if !sp.closed || n < 3 || !(0..n).any(straight_corner) {
if !sp.closed || n < 2 {
sp.to_bezpath_into(out);
return;
}
let pts: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
let mut first = true;
for i in 0..n {
let c = opts.corners[i % 4];
let p = pts[i];
let prev = pts[(i + n - 1) % n];
let next = pts[(i + 1) % n];
if c.is_none() || !straight_corner(i) || !straight_corner((i + n - 1) % n) && sp.anchors[(i + n - 1) % n].has_out() {
if first {
out.move_to(p);
first = false;
} else {
out.line_to(p);
// Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments
// `i - 1` and `i`, and takes at most half of each.
let segs: Vec<CubicBez> = (0..n).map(|i| sp.segment(i)).collect();
let lines: Vec<bool> = (0..n).map(|i| sp.segment_is_line(i)).collect();
// Straight sides are measured (and trimmed below) exactly; curves by arc length.
let lens: Vec<f64> = segs.iter().zip(&lines).map(|(c, line)| if *line { (c.p3 - c.p0).hypot() } else { c.arclen(ARC_ACCURACY) }).collect();
let size: Vec<f64> = (0..n)
.map(|i| {
let c = opts.corners[i % 4];
let prev = (i + n - 1) % n;
if c.is_none() || !is_corner(&segs[prev], &segs[i]) {
return 0.0;
}
continue;
}
let din = prev - p;
let dout = next - p;
let (lin, lout) = (din.hypot(), dout.hypot());
if lin < 1e-9 || lout < 1e-9 {
continue;
}
let s = c.size.min(lin / 2.0).min(lout / 2.0);
let ui = din / lin;
let uo = dout / lout;
let a = p + ui * s; // entry point on incoming edge
let b = p + uo * s; // exit point on outgoing edge
if first {
out.move_to(a);
first = false;
c.size.min(lens[prev] / 2.0).min(lens[i] / 2.0).max(0.0)
})
.collect();
if size.iter().all(|s| *s <= 0.0) {
sp.to_bezpath_into(out);
return;
}
// Each segment without the parts its corners take.
let trimmed: Vec<CubicBez> = (0..n)
.map(|i| {
let (s0, s1) = (size[i], size[(i + 1) % n]);
if lines[i] {
let c = &segs[i];
let u = if lens[i] > 1e-12 { (c.p3 - c.p0) / lens[i] } else { Vec2::ZERO };
let (a, b) = (c.p0 + u * s0, c.p3 - u * s1);
return CubicBez::new(a, a, b, b);
}
let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 };
let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 };
segs[i].subsegment(t0..t1.max(t0))
})
.collect();
out.move_to(trimmed[0].p0);
for i in 0..n {
let seg = &trimmed[i];
if lines[i] {
out.line_to(seg.p3);
} else {
out.line_to(a);
out.curve_to(seg.p1, seg.p2, seg.p3);
}
// The corner at the segment's end, from where this segment stops to where the next starts.
let j = (i + 1) % n;
let next = &trimmed[j];
if size[j] > 0.0 {
match (end_dir(seg).or_else(|| end_dir(&segs[i])), start_dir(next).or_else(|| start_dir(&segs[j]))) {
(Some(ta), Some(tb)) => corner_shape(out, opts.corners[j % 4].shape, sp.anchors[j].p, seg.p3, next.p0, -ta, tb, size[j]),
_ => out.line_to(next.p0),
}
}
corner_shape(out, c.shape, p, a, b, ui, uo, s);
}
out.close_path();
}
@@ -211,6 +247,67 @@ mod tests {
assert!((crate::shapes::KAPPA - 0.5523).abs() < 1e-4);
}
fn nearest(bp: &BezPath, p: Point) -> f64 {
use kurbo::ParamCurveNearest;
bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min)
}
/// A quarter disc of radius 100 centred on (0, 100): its arc from (0, 0) to (100, 100).
fn quarter_disc() -> PathData {
let k = 100.0 * crate::shapes::KAPPA;
let mut a0 = crate::path::Anchor::corner(Point::new(0.0, 0.0));
a0.h_out = Point::new(k, 0.0);
let mut a1 = crate::path::Anchor::corner(Point::new(100.0, 100.0));
a1.h_in = Point::new(100.0, 100.0 - k);
let a2 = crate::path::Anchor::corner(Point::new(0.0, 100.0));
PathData::new(vec![SubPath { anchors: vec![a0, a1, a2], closed: true }])
}
#[test]
fn rounded_corners_keep_curved_sides_and_round_where_an_arc_meets_a_line() {
let p = quarter_disc();
let bp = apply(&p, &CornerOptions::uniform(CornerShape::Rounded, 20.0));
// Still a quarter disc (area pi/4 * 100^2 = 7854), less three small rounded corners; a
// triangle would be 5000.
let area = bp.area().abs();
assert!(area > 7300.0 && area < 7854.0, "area {area}");
// The middle of the arc is kept.
let mid = Point::new(100.0 * std::f64::consts::FRAC_1_SQRT_2, 100.0 - 100.0 * std::f64::consts::FRAC_1_SQRT_2);
assert!(nearest(&bp, mid) < 0.05, "{}", nearest(&bp, mid));
// All three corners are rounded off: the line-line one and both where the arc meets a line.
for corner in [Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] {
assert!(nearest(&bp, corner) > 3.0, "{corner:?} is still sharp");
}
}
#[test]
fn straight_sides_are_trimmed_exactly() {
let r = rectangle(Rect::new(0.0, 0.0, 100.0, 60.0));
let bp = apply(&r, &CornerOptions::uniform(CornerShape::Bevel, 10.0));
// Every point a bevelled rectangle goes through sits exactly 10 pt from a corner.
let pts: Vec<Point> = bp
.elements()
.iter()
.filter_map(|e| match e {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p),
_ => None,
})
.collect();
assert_eq!(pts.len(), 9, "{pts:?}");
for p in pts {
assert!([0.0, 10.0, 90.0, 100.0].contains(&p.x) && [0.0, 10.0, 50.0, 60.0].contains(&p.y), "{p:?}");
}
}
#[test]
fn smooth_points_are_not_corners() {
let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0));
for shape in CornerShape::ALL.into_iter().skip(1) {
let bp = apply(&e, &CornerOptions::uniform(shape, 20.0));
assert!((bp.area().abs() - e.to_bezpath().area().abs()).abs() < 1e-6, "{shape:?}");
}
}
#[test]
fn size_is_clamped_to_half_edge() {
let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
+21
View File
@@ -47,6 +47,22 @@ impl PathWarp {
None
}
/// The arc length (as `at` measures it, so flipped paths count from their end) of the point
/// of the path nearest `p`.
pub fn nearest(&self, p: Point) -> f64 {
use kurbo::ParamCurveNearest;
let mut best = (f64::INFINITY, 0.0);
let mut before = 0.0;
for (seg, len) in &self.segs {
let n = seg.nearest(p, 1e-6);
if n.distance_sq < best.0 {
best = (n.distance_sq, before + seg.subsegment(0.0..n.t).arclen(1e-4));
}
before += len;
}
if self.flip { self.total - best.1 } else { best.1 }
}
/// Transform for something laid out on a straight line (x along the line, y down from the
/// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with
/// the path, its line at `baseline` on the path.
@@ -76,5 +92,10 @@ mod tests {
assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9);
let flipped = PathWarp::new(&bp, true);
assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9);
// The nearest point by arc length, off the path and on it; flipped counts from the end.
assert!((w.nearest(Point::new(120.0, 50.0)) - 150.0).abs() < 1e-6);
assert!((w.nearest(Point::new(120.0, 150.0)) - 200.0).abs() < 1e-6, "past the end: the end");
assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6);
assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6);
}
}
+19 -10
View File
@@ -33,10 +33,17 @@ impl Default for ExportOptions {
}
/// Export a document as an IDML package (ZIP bytes).
///
/// IDML has one sort key per index topic path. If native references disagree, the first
/// reference serialized for that path supplies its key, including an empty/default key.
/// Later references cannot overwrite it; no synthetic topic names or sort keys are created.
/// Consistent references retain their keys. Conflicting per-reference keys cannot all survive
/// this topic-level representation. This policy also applies to [`export_idml_with`].
pub fn export_idml(doc: &Document) -> Vec<u8> {
export_idml_with(doc, &ExportOptions::default())
}
/// Export with options, using the index sort-key conflict policy of [`export_idml`].
pub fn export_idml_with(doc: &Document, opts: &ExportOptions) -> Vec<u8> {
let mut ex = Ex::new(doc, opts);
let parts = ex.build();
@@ -133,8 +140,8 @@ struct Ex<'a> {
threads: HashMap<u64, (Option<u64>, Option<u64>)>,
/// Cross-reference sources written so far: (Self, target anchor).
xref_sources: Vec<(String, u64)>,
/// Index topics (paths) and their See / See also cross-references.
index_topics: BTreeSet<Vec<String>>,
/// Index topic path → first serialized sort key; See / See also cross-references.
index_topics: BTreeMap<Vec<String>, String>,
index_xrefs: Vec<(Vec<String>, &'static str, String)>,
}
@@ -148,7 +155,7 @@ impl<'a> Ex<'a> {
fonts: BTreeMap::new(),
threads: HashMap::new(),
xref_sources: Vec::new(),
index_topics: BTreeSet::new(),
index_topics: BTreeMap::new(),
index_xrefs: Vec::new(),
};
ex.assign_swatch_ids();
@@ -1397,7 +1404,7 @@ impl<'a> Ex<'a> {
n!(drop_cap_lines, "DropCapLines");
n!(drop_cap_chars, "DropCapCharacters");
if a.drop_cap_align_left.is_some() || a.drop_cap_scale_descenders.is_some() {
let d = designcraft_doc::ParaProps::default();
let d = parent.map(|name| self.d.styles.resolve_para_style(name).0).unwrap_or_default();
let bits = u32::from(a.drop_cap_align_left.unwrap_or(d.drop_cap_align_left))
| u32::from(a.drop_cap_scale_descenders.unwrap_or(d.drop_cap_scale_descenders)) << 1;
el.set("DropcapDetail", bits);
@@ -1665,7 +1672,7 @@ impl<'a> Ex<'a> {
.attr("TextAlignment", names::path_align_out(pt.align))
.attr("FlipPathEffect", if pt.flip { "Flipped" } else { "NotFlipped" })
.attr("StartBracket", num(pt.start))
.attr("EndBracket", num(len)),
.attr("EndBracket", num(pt.end.map_or(len, |e| e.min(len)))),
);
} else if let Content::Text(t) = &it.content {
let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None));
@@ -1845,12 +1852,12 @@ impl<'a> Ex<'a> {
fn index_el(&mut self) -> El {
let mut topics = self.index_topics.clone();
for (_, _, target) in &self.index_xrefs {
topics.insert(vec![target.clone()]);
topics.entry(vec![target.clone()]).or_default();
}
fn build(ex: &mut Ex, topics: &BTreeSet<Vec<String>>, prefix: &[String]) -> Vec<El> {
fn build(ex: &mut Ex, topics: &BTreeMap<Vec<String>, String>, prefix: &[String]) -> Vec<El> {
let mut out = Vec::new();
for t in topics.iter().filter(|t| t.len() == prefix.len() + 1 && t.starts_with(prefix)) {
let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", "").attr("Name", t.last().map_or("", String::as_str));
for (t, sort) in topics.iter().filter(|(t, _)| t.len() == prefix.len() + 1 && t.starts_with(prefix)) {
let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", sort).attr("Name", t.last().map_or("", String::as_str));
for (from, kind, target) in ex.index_xrefs.clone() {
if from == *t {
el.push(
@@ -2105,7 +2112,9 @@ impl<'a> Ex<'a> {
if let Some(r) = s.index_refs.get(k).filter(|r| !r.topics.is_empty()) {
use designcraft_doc::index::IndexRange as R;
for n in 1..=r.topics.len() {
self.index_topics.insert(r.topics[..n].to_vec());
// A shared topic has one key: keep the first reference's
// value (even empty), rather than inventing a merged key.
self.index_topics.entry(r.topics[..n].to_vec()).or_insert_with(|| r.sort.get(n - 1).cloned().unwrap_or_default());
}
let (kind, limit) = match &r.range {
R::CurrentPage => (Some("CurrentPage"), None),
+307 -41
View File
@@ -206,11 +206,32 @@ struct Importer<'r> {
/// Cross-reference format Self → name.
xref_format_names: HashMap<String, String>,
xref_formats: Vec<designcraft_doc::XrefFormat>,
/// Index topic Self → topic path; topic See / See also cross-references.
index_topics: HashMap<String, Vec<String>>,
/// Index topic Self → topic path and per-level sort keys; topic cross-references.
index_topics: HashMap<String, IndexTopic>,
index_xrefs: Vec<designcraft_doc::IndexRef>,
}
/// IDML stores a sort key on each topic, rather than on its page references.
#[derive(Clone, Default)]
struct IndexTopic {
path: Vec<String>,
sort: Vec<String>,
}
impl IndexTopic {
fn child(&self, topic: &El) -> Self {
let mut path = self.path.clone();
path.push(topic.get("Name").unwrap_or("").to_string());
let mut sort = self.sort.clone();
if let Some(key) = topic.get("SortOrder").filter(|key| !key.is_empty()) {
// Keep empty positions before a child's key, but omit trailing defaults.
sort.resize(self.path.len(), String::new());
sort.push(key.to_string());
}
Self { path, sort }
}
}
fn lab_to_color(l: f32, a: f32, b: f32) -> Color {
let rgb = lab::xyz_to_srgb(lab::lab_to_xyz(lab::Lab::new(l, a, b)));
Color::rgb(rgb[0], rgb[1], rgb[2])
@@ -456,7 +477,7 @@ impl<'r> Importer<'r> {
self.xref_format(e);
}
if let Some(ix) = top.iter().find(|e| e.local() == "Index") {
self.index_topics_of(ix, &[]);
self.index_topics_of(ix, &IndexTopic::default());
}
// Stories (ids first so frames can reference them).
for e in top.iter().filter(|e| e.local() == "Story") {
@@ -772,8 +793,8 @@ impl<'r> Importer<'r> {
/// A `TextFramePreference`, with its column rule colour resolved to a swatch.
fn frame_options(&mut self, prefs: &[&El]) -> TextFrameOptions {
let mut o = text_frame_options(prefs);
if let Some(r) = Prefs(prefs).get("ColumnRuleStrokeColor") {
o.column_rule_color = self.swatch_ref(r);
if let Some(r) = Prefs(prefs).prop("ColumnRuleStrokeColor") {
o.column_rule_color = self.swatch_ref(&r);
}
o
}
@@ -1013,33 +1034,31 @@ impl<'r> Importer<'r> {
}
/// Index topics (recursively); at the root, their See / See also cross-references too.
fn index_topics_of(&mut self, e: &El, prefix: &[String]) {
fn index_topics_of(&mut self, e: &El, prefix: &IndexTopic) {
for t in e.find_all("Topic") {
let mut path = prefix.to_vec();
path.push(t.get("Name").unwrap_or("").to_string());
let topic = prefix.child(t);
if let Some(me) = t.get("Self") {
self.index_topics.insert(me.to_string(), path.clone());
self.index_topics.insert(me.to_string(), topic.clone());
}
self.index_topics_of(t, &path);
self.index_topics_of(t, &topic);
}
if !prefix.is_empty() {
if !prefix.path.is_empty() {
return;
}
let mut stack = vec![(e, Vec::<String>::new())];
while let Some((el, path)) = stack.pop() {
let mut stack = vec![(e, IndexTopic::default())];
while let Some((el, prefix)) = stack.pop() {
for t in el.find_all("Topic") {
let mut p = path.clone();
p.push(t.get("Name").unwrap_or("").to_string());
let topic = prefix.child(t);
for x in t.find_all("CrossReference") {
let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|p| p.join(": ")).unwrap_or_default();
let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|t| t.path.join(": ")).unwrap_or_default();
use designcraft_doc::index::IndexRange as R;
let range = match x.get("CrossReferenceType") {
Some("SeeAlso") | Some("SeeAlsoHerein") => R::SeeAlso(target),
_ => R::See(target),
};
self.index_xrefs.push(designcraft_doc::IndexRef { topics: p.clone(), sort: vec![], range });
self.index_xrefs.push(designcraft_doc::IndexRef { topics: topic.path.clone(), sort: topic.sort.clone(), range });
}
stack.push((t, p));
stack.push((t, topic));
}
}
}
@@ -1810,14 +1829,14 @@ impl<'r> Importer<'r> {
}
"PageReference" => {
use designcraft_doc::index::IndexRange as R;
let topics = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default();
let topic = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default();
let range = match c.get("PageReferenceType") {
Some("ToEndOfStory") | Some("ToEndOfDocument") | Some("ToEndOfSection") => R::ToEndOfStory,
Some("ForNextNParagraphs") => R::NextParagraphs(c.num("PageReferenceLimit").unwrap_or(1.0).max(1.0) as u32),
Some("SuppressPageNumbers") => R::SuppressPageRange,
_ => R::CurrentPage,
};
b.index_refs.push(designcraft_doc::IndexRef { topics, sort: vec![], range });
b.index_refs.push(designcraft_doc::IndexRef { topics: topic.path, sort: topic.sort, range });
b.push(&designcraft_doc::INDEX_MARK.to_string(), cf);
}
"CrossReferenceSource" => {
@@ -2205,6 +2224,11 @@ impl<'r> Importer<'r> {
let options = TextFrameOptions {
path: Some(designcraft_doc::PathType {
start: tp.num("StartBracket").unwrap_or(0.0).max(0.0),
// At (or past) the path's length it is the path's end: no end bracket.
end: tp
.num("EndBracket")
.filter(|e| e.is_finite() && *e >= 0.0)
.filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01),
flip: tp.get("FlipPathEffect") == Some("Flipped"),
align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")),
}),
@@ -2231,7 +2255,7 @@ impl<'r> Importer<'r> {
let prefs: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).filter_map(|s| s.find("TextFramePreference")).collect();
let mut options = text_frame_options(&prefs);
// `prefs` borrows the object styles, so the rule colour's swatch is resolved after it.
if let Some(r) = Prefs(&prefs).get("ColumnRuleStrokeColor").map(str::to_string) {
if let Some(r) = Prefs(&prefs).prop("ColumnRuleStrokeColor") {
options.column_rule_color = self.swatch_ref(&r);
}
if let Some(g) = e.find("BaselineFrameGridOption")
@@ -2554,10 +2578,10 @@ fn ace_char(v: &str) -> Option<char> {
}
fn margins_of(m: &El) -> (Margins, Columns) {
let g = |k: &str, d: f64| m.num(k).unwrap_or(d);
let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d);
(
Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) },
Columns { count: g("ColumnCount", 1.0).max(1.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
)
}
@@ -2566,14 +2590,14 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
let e = Prefs(prefs);
let mut o = TextFrameOptions::default();
if let Some(v) = e.num("TextColumnCount") {
o.columns = v.max(1.0) as u32;
o.columns = v.clamp(1.0, 216.0) as u32;
}
if let Some(v) = e.num("TextColumnGutter") {
o.gutter = v;
}
o.columns_kind = if e.get("UseFixedColumnWidth") == Some("true") {
o.columns_kind = if e.prop("UseFixedColumnWidth").as_deref().map(str::trim) == Some("true") {
ColumnsKind::FixedWidth
} else if e.get("UseFlexibleColumnWidth") == Some("true") {
} else if e.prop("UseFlexibleColumnWidth").as_deref().map(str::trim) == Some("true") {
ColumnsKind::FlexibleWidth
} else {
ColumnsKind::FixedNumber
@@ -2583,33 +2607,33 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
{
o.column_width = v;
}
o.balance_columns = e.get("VerticalBalanceColumns") == Some("true");
o.balance_columns = e.prop("VerticalBalanceColumns").as_deref().map(str::trim) == Some("true");
if let Some(p) = prefs.iter().find(|p| p.prop("InsetSpacing").is_some())
&& let Some(inset) = inset_spacing(p)
{
o.inset = inset;
}
if let Some(v) = e.get("VerticalJustification") {
o.vertical_justification = names::vj_in(v);
if let Some(v) = e.prop("VerticalJustification") {
o.vertical_justification = names::vj_in(v.trim());
}
if let Some(v) = e.num("VerticalThreshold") {
o.vj_paragraph_spacing_limit = v;
}
if let Some(v) = e.get("FirstBaselineOffset") {
o.first_baseline = names::first_baseline_in(v);
if let Some(v) = e.prop("FirstBaselineOffset") {
o.first_baseline = names::first_baseline_in(v.trim());
}
if let Some(v) = e.num("MinimumFirstBaselineOffset") {
o.first_baseline_min = v;
}
o.ignore_wrap = e.get("IgnoreWrap") == Some("true");
if let Some(v) = e.get("AutoSizingType") {
o.auto_size = names::auto_size_in(v);
o.ignore_wrap = e.prop("IgnoreWrap").as_deref().map(str::trim) == Some("true");
if let Some(v) = e.prop("AutoSizingType") {
o.auto_size = names::auto_size_in(v.trim());
}
if let Some(v) = e.get("AutoSizingReferencePoint") {
o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8;
if let Some(v) = e.prop("AutoSizingReferencePoint") {
o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v.trim()).unwrap_or(1) as u8;
}
// Column rules (the colour is a swatch reference: see `Importer::frame_options`).
o.column_rule = e.get("ColumnRuleOverride") == Some("true");
o.column_rule = e.prop("ColumnRuleOverride").as_deref().map(str::trim) == Some("true");
let finite = |k: &str| e.num(k).filter(|v| v.is_finite());
if let Some(v) = finite("ColumnRuleStrokeWidth") {
o.column_rule_weight = v.clamp(0.0, 1000.0);
@@ -2670,15 +2694,16 @@ fn inherits_drop_cap_style<'a>(styles: &'a Styles, mut name: Option<&'a str>) ->
false
}
/// Attribute lookup over several elements: the first that has it.
/// Property lookup over preference levels: the nearest level wins, attributes before children.
struct Prefs<'a>(&'a [&'a El]);
impl Prefs<'_> {
fn get(&self, k: &str) -> Option<&str> {
self.0.iter().find_map(|e| e.get(k))
fn prop(&self, k: &str) -> Option<String> {
self.0.iter().find_map(|e| e.prop(k))
}
fn num(&self, k: &str) -> Option<f64> {
self.get(k).and_then(|v| v.trim().parse().ok())
// The nearest preference wins; within it, attributes precede property children.
self.prop(k).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
}
}
@@ -2738,3 +2763,244 @@ pub(crate) fn uri_to_path(uri: &str) -> String {
};
percent_decode(rest)
}
#[cfg(test)]
mod numeric_property_tests {
use super::*;
fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El {
let mut e = El::new("TextFramePreference");
for (key, value) in attrs {
e.set(key, *value);
}
let mut p = El::new("Properties");
for (key, value) in props {
p.push(El::new(key).text(*value));
}
e.push(p);
e
}
#[test]
fn numeric_property_forms_import_every_frame_option() {
let local = pref(
&[("UseFixedColumnWidth", "true")],
&[
("TextColumnCount", "3"),
("TextColumnGutter", "17"),
("TextColumnFixedWidth", "123"),
("VerticalThreshold", "19"),
("MinimumFirstBaselineOffset", "8"),
("ColumnRuleStrokeWidth", "2"),
("ColumnRuleStrokeTint", "25"),
("ColumnRuleOffset", "3"),
("ColumnRuleTopInset", "4"),
("ColumnRuleBottomInset", "5"),
],
);
let o = text_frame_options(&[&local]);
assert_eq!((o.columns, o.gutter, o.column_width), (3, 17., 123.));
assert_eq!((o.vj_paragraph_spacing_limit, o.first_baseline_min), (19., 8.));
assert_eq!(
(o.column_rule_weight, o.column_rule_tint, o.column_rule_offset, o.column_rule_top_inset, o.column_rule_bottom_inset),
(2., 0.25, 3., 4., 5.)
);
}
#[test]
fn numeric_property_precedence_is_nearest_level_then_attribute() {
let local = pref(&[("TextColumnCount", "4")], &[("TextColumnCount", "5"), ("TextColumnGutter", "17")]);
let child = pref(
&[("TextColumnGutter", "90"), ("MinimumFirstBaselineOffset", "8")],
&[("MinimumFirstBaselineOffset", "9"), ("VerticalThreshold", "19")],
);
let base = pref(&[("VerticalThreshold", "99")], &[("ColumnRuleStrokeWidth", "2")]);
let o = text_frame_options(&[&local, &child, &base]);
assert_eq!((o.columns, o.gutter, o.first_baseline_min, o.vj_paragraph_spacing_limit, o.column_rule_weight), (4, 17., 8., 19., 2.));
// An explicit invalid value still masks inherited values, as attribute lookup did.
let invalid = pref(&[("TextColumnCount", "bad")], &[("TextColumnCount", "6")]);
assert_eq!(text_frame_options(&[&invalid, &local]).columns, TextFrameOptions::default().columns);
}
}
#[cfg(test)]
mod frame_preference_safety_tests {
use super::*;
fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El {
let mut e = El::new("TextFramePreference");
for (key, value) in attrs {
e.set(key, *value);
}
let mut p = El::new("Properties");
for (key, value) in props {
p.push(El::new(key).text(*value));
}
e.push(p);
e
}
#[test]
fn frame_preference_nonfinite_numbers_keep_finite_defaults() {
const KEYS: [&str; 10] = [
"TextColumnCount",
"TextColumnGutter",
"TextColumnFixedWidth",
"VerticalThreshold",
"MinimumFirstBaselineOffset",
"ColumnRuleStrokeWidth",
"ColumnRuleStrokeTint",
"ColumnRuleOffset",
"ColumnRuleTopInset",
"ColumnRuleBottomInset",
];
let fallback = pref(&[("UseFixedColumnWidth", "true")], &[]);
let expected = text_frame_options(&[&fallback]);
for value in ["NaN", "inf", "-inf", "1e309"] {
for attribute in [true, false] {
let values: Vec<_> = KEYS.iter().map(|key| (*key, value)).collect();
let mut invalid = if attribute { pref(&values, &[]) } else { pref(&[], &values) };
invalid.set("UseFixedColumnWidth", "true");
let inherited = pref(&[], &[("TextColumnCount", "7"), ("TextColumnGutter", "9")]);
assert_eq!(text_frame_options(&[&invalid, &inherited]), expected, "{value}, attribute={attribute}");
}
}
}
#[test]
fn frame_preference_property_booleans_enums_and_precedence() {
let base = pref(
&[
("UseFixedColumnWidth", "true"),
("UseFlexibleColumnWidth", "false"),
("VerticalBalanceColumns", "false"),
("IgnoreWrap", "false"),
("ColumnRuleOverride", "false"),
("VerticalJustification", "TopAlign"),
("FirstBaselineOffset", "AscentOffset"),
("AutoSizingType", "Off"),
("AutoSizingReferencePoint", "TopCenterPoint"),
],
&[],
);
let local = pref(
&[],
&[
("UseFixedColumnWidth", "false"),
("UseFlexibleColumnWidth", "true"),
("VerticalBalanceColumns", "true"),
("IgnoreWrap", "true"),
("ColumnRuleOverride", "true"),
("VerticalJustification", "BottomAlign"),
("FirstBaselineOffset", "FixedHeight"),
("AutoSizingType", "HeightAndWidth"),
("AutoSizingReferencePoint", "BottomRightPoint"),
],
);
let o = text_frame_options(&[&local, &base]);
assert_eq!(o.columns_kind, ColumnsKind::FlexibleWidth);
assert!(o.balance_columns && o.ignore_wrap && o.column_rule);
assert_eq!(o.vertical_justification, designcraft_doc::VerticalJustification::Bottom);
assert_eq!(o.first_baseline, designcraft_doc::FirstBaseline::Fixed);
assert_eq!(o.auto_size, designcraft_doc::AutoSize::HeightAndWidth);
assert_eq!(o.auto_size_ref, 8);
let fixed = pref(&[], &[("UseFixedColumnWidth", "true")]);
assert_eq!(text_frame_options(&[&fixed, &local, &base]).columns_kind, ColumnsKind::FixedWidth);
// At one level an attribute wins over a conflicting property child, for every key.
let mut same_level = local.clone();
for (key, value) in &base.attrs {
same_level.set(key, value);
}
assert_eq!(text_frame_options(&[&same_level, &local]), text_frame_options(&[&base]));
// Malformed nearer values mask ancestors and use the normal per-field fallback.
let mut malformed = El::new("TextFramePreference");
for (key, _) in &base.attrs {
malformed.set(key, "invalid");
}
assert_eq!(text_frame_options(&[&malformed, &local]), TextFrameOptions { auto_size_ref: 1, ..Default::default() });
}
}
#[cfg(test)]
mod page_column_import_safety_tests {
use super::*;
#[test]
fn frame_preference_page_columns_are_bounded_and_finite() {
for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-10", 1), ("NaN", 1), ("inf", 1)] {
let m = El::new("MarginPreference")
.attr("ColumnCount", raw)
.attr("ColumnGutter", "NaN")
.attr("Top", "inf")
.attr("Bottom", "-inf")
.attr("Left", "NaN")
.attr("Right", "1e309");
let (margins, columns) = margins_of(&m);
assert_eq!(columns.count, expected);
assert_eq!(columns.gutter, 12.);
assert_eq!(margins, Margins::uniform(36.));
}
}
}
#[cfg(test)]
mod frame_preference_scalar_tests {
use super::*;
#[test]
fn frame_preference_scalar_whitespace() {
let values = [
("UseFixedColumnWidth", "true"),
("UseFlexibleColumnWidth", "false"),
("VerticalBalanceColumns", "true"),
("IgnoreWrap", "true"),
("ColumnRuleOverride", "true"),
("VerticalJustification", "BottomAlign"),
("FirstBaselineOffset", "FixedHeight"),
("AutoSizingType", "HeightAndWidth"),
("AutoSizingReferencePoint", "BottomRightPoint"),
];
let preference = |properties: bool, padded: bool, fixed: bool| {
let mut e = El::new("TextFramePreference");
let mut props = El::new("Properties");
for (key, raw) in values {
let raw = match key {
"UseFixedColumnWidth" if !fixed => "false",
"UseFlexibleColumnWidth" if !fixed => "true",
_ => raw,
};
let value = if padded { format!("\n {raw}\t ") } else { raw.to_string() };
if properties {
props.push(El::new(key).text(value));
} else {
e.set(key, value);
}
}
e.push(props);
e
};
for fixed in [false, true] {
let clean = preference(false, false, fixed);
let expected = text_frame_options(&[&clean]);
assert_eq!(expected.columns_kind, if fixed { ColumnsKind::FixedWidth } else { ColumnsKind::FlexibleWidth });
assert!(expected.balance_columns && expected.ignore_wrap && expected.column_rule);
assert_eq!(expected.vertical_justification, designcraft_doc::VerticalJustification::Bottom);
assert_eq!(expected.first_baseline, designcraft_doc::FirstBaseline::Fixed);
assert_eq!(expected.auto_size, designcraft_doc::AutoSize::HeightAndWidth);
assert_eq!(expected.auto_size_ref, 8);
for properties in [false, true] {
let padded = preference(properties, true, fixed);
assert_eq!(text_frame_options(&[&padded]), expected);
}
// A malformed nearest attribute masks both its own valid child and farther values.
let mut invalid = preference(true, true, fixed);
for (key, _) in values {
invalid.set(key, " invalid ");
}
assert_eq!(text_frame_options(&[&invalid, &clean]), TextFrameOptions { auto_size_ref: 1, ..Default::default() });
}
// Object identities use the raw property lookup, not scalar whitespace normalization.
let exact = El::new("TextFramePreference").child(El::new("Properties").child(El::new("ColumnRuleStrokeColor").text(" Color/Named ")));
assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named "));
}
}
+67
View File
@@ -9,6 +9,7 @@ use super::*;
mod assets;
mod decorations;
mod index_sort;
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
use zip::write::SimpleFileOptions;
@@ -2102,3 +2103,69 @@ fn frames_inherit_object_style_corners_and_text_frame_options() {
assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Center, FirstBaseline::Leading, 4.0));
assert_eq!((o.ignore_wrap, o.auto_size, o.auto_size_ref), (true, AutoSize::HeightOnly, 4));
}
#[test]
fn type_on_a_path_keeps_its_start_and_end_brackets() {
use designcraft_geom::{Anchor, PathData, Point, SubPath};
for end in [Some(250.0), None] {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(100.0, 300.0)), Anchor::corner(Point::new(500.0, 300.0))], false));
let mut it = designcraft_doc::Item::new(id, lid, Shape::GraphicLine, line);
let sid = designcraft_doc::StoryId(d.alloc());
let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() };
tf.options.path = Some(designcraft_doc::PathType { start: 30.0, end, ..Default::default() });
it.content = designcraft_doc::Content::Text(tf);
let mut story = designcraft_doc::Story::with_text(sid, "Along", Default::default());
story.frames = vec![id];
d.stories.insert(sid, std::sync::Arc::new(story));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let back = import_idml(&export_idml(&d)).unwrap();
let pt = back.spreads[0].items[0].text_frame().unwrap().options.path.clone().unwrap();
assert!((pt.start - 30.0).abs() < 1e-6, "{pt:?}");
assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket");
}
}
#[test]
fn frame_preference_import_caps_column_count_without_composition() {
for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-100", 1), ("0", 1), ("NaN", 1), ("inf", 1), ("-inf", 1)] {
for attribute in [true, false] {
let preference = if attribute {
format!(r#"<TextFramePreference TextColumnCount="{raw}"/>"#)
} else {
format!(
r#"<TextFramePreference><Properties><TextColumnCount type="number">{raw}</TextColumnCount></Properties></TextFramePreference>"#
)
};
let document = format!(
r#"<Document><Story Self="s"/><Spread Self="spread"><Page Self="page"/><TextFrame Self="frame" ParentStory="s">{preference}</TextFrame></Spread></Document>"#
);
let d = import_idml_with(&zip_files(&[("designmap.xml", &document)]), &|_| None).unwrap();
let frame = d.spreads[0].items[0].text_frame().unwrap();
// Inspect imported data only: never compose an unbounded input value.
assert_eq!(frame.options.columns, expected, "{raw}, attribute={attribute}");
}
}
}
#[test]
fn frame_preference_property_color_resolves_for_frames_and_object_styles() {
let document = r#"<Document>
<Color Self="Color/Near" Model="Process" Space="RGB" ColorValue="255 0 0" Name="Near" Visible="true"/>
<Color Self="Color/Far" Model="Process" Space="RGB" ColorValue="0 0 255" Name="Far" Visible="true"/>
<RootObjectStyleGroup Self="root">
<ObjectStyle Self="ObjectStyle/Base" Name="Base" EnableTextFrameGeneralOptions="true"><TextFramePreference ColumnRuleStrokeColor="Color/Far"/></ObjectStyle>
<ObjectStyle Self="ObjectStyle/Child" Name="Child" EnableTextFrameGeneralOptions="true"><Properties><BasedOn type="object">ObjectStyle/Base</BasedOn></Properties><TextFramePreference><Properties><ColumnRuleStrokeColor type="object">Color/Near</ColumnRuleStrokeColor></Properties></TextFramePreference></ObjectStyle>
</RootObjectStyleGroup>
<Story Self="s"/><Spread Self="spread"><Page Self="page"/>
<TextFrame Self="local" ParentStory="s" AppliedObjectStyle="ObjectStyle/Base"><TextFramePreference><Properties><ColumnRuleStrokeColor type="object">Color/Near</ColumnRuleStrokeColor></Properties></TextFramePreference></TextFrame>
<TextFrame Self="inherited" ParentStory="s" AppliedObjectStyle="ObjectStyle/Child"/>
<TextFrame Self="attribute" ParentStory="s" AppliedObjectStyle="ObjectStyle/Child"><TextFramePreference ColumnRuleStrokeColor="Color/Far"><Properties><ColumnRuleStrokeColor type="object">Color/Near</ColumnRuleStrokeColor></Properties></TextFramePreference></TextFrame>
<TextFrame Self="invalid" ParentStory="s" AppliedObjectStyle="ObjectStyle/Child"><TextFramePreference><Properties><ColumnRuleStrokeColor type="object">missing</ColumnRuleStrokeColor></Properties></TextFramePreference></TextFrame>
</Spread></Document>"#;
let d = import_idml_with(&zip_files(&[("designmap.xml", document)]), &|_| None).unwrap();
let colors: Vec<_> = d.spreads[0].items.iter().map(|it| it.text_frame().unwrap().options.column_rule_color.as_str()).collect();
assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]);
assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near");
}
+240
View File
@@ -0,0 +1,240 @@
//! Original synthetic fixtures: no Adobe-generated files or implementation details.
//! Behavioral reference: https://developer.adobe.com/indesign/uxp/dom/api/t/topic/
use std::io::Read;
use designcraft_doc::IndexRef;
use designcraft_doc::index::{self, IndexRange, IndexSpec, Resolved};
use super::*;
use crate::xml::El;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).to_string()).collect()
}
fn reference(topics: &[&str], sort: &[&str], range: IndexRange) -> IndexRef {
IndexRef { topics: strings(topics), sort: strings(sort), range }
}
fn references(doc: &Document) -> Vec<IndexRef> {
doc.stories.values().flat_map(|s| s.index_refs.iter().map(|r| r.as_ref().clone())).collect()
}
fn native(refs: Vec<IndexRef>) -> Document {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), layer, "Source", ParaFormat::default()).unwrap();
let story = doc.story_mut(sid).unwrap();
for r in refs {
story.insert_index_ref(story.len(), r);
}
doc.check().unwrap();
doc
}
fn import_xml(index: &str, refs: &str) -> Document {
let map = format!(
r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
{index}
<Layer Self="L" Name="Art"/>
<idPkg:Story src="Stories/s.xml"/>
<idPkg:Spread src="Spreads/s.xml"/>
</Document>"#
);
let story = format!(
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s"><ParagraphStyleRange><CharacterStyleRange>
<Content>Source</Content>{refs}
</CharacterStyleRange></ParagraphStyleRange></Story>
</idPkg:Story>"#
);
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Spread Self="sp"><Page Self="p" GeometricBounds="0 0 240 240"/>
<TextFrame Self="frame" ParentStory="s" ItemLayer="L">
<Properties><PathGeometry><GeometryPathType><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="200 0"/>
<PathPointType Anchor="200 200"/><PathPointType Anchor="0 200"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
</TextFrame>
</Spread>
</idPkg:Spread>"#;
import_idml(&zip_files(&[("designmap.xml", &map), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])).unwrap()
}
/// Inspect the exported XML directly, without calling the IDML importer.
fn exported_index(doc: &Document) -> El {
let bytes = export_idml(doc);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut map = String::new();
zip.by_name("designmap.xml").unwrap().read_to_string(&mut map).unwrap();
crate::xml::parse(map.as_bytes()).unwrap().find("Index").unwrap().clone()
}
fn topic<'a>(index: &'a El, path: &[&str]) -> &'a El {
let mut el = index;
for name in path {
el = el.find_all("Topic").find(|t| t.get("Name") == Some(*name)).unwrap();
}
el
}
#[test]
fn imports_synthetic_sort_keys_for_nested_topics_and_cross_references() {
let doc = import_xml(
r#"<Index Self="ix">
<Topic Self="z" Name="Zulu" SortOrder="Alpha"/>
<Topic Self="b" Name="Beta"/>
<Topic Self="empty" Name="Empty" SortOrder=""/>
<Topic Self="parent" Name="Parent" SortOrder="">
<Topic Self="child" Name="É &amp; &lt;leaf&gt;" SortOrder=" A&amp;&lt;é&gt; ">
<CrossReference Self="see" ReferencedTopic="b" CrossReferenceType="See"/>
<CrossReference Self="also" ReferencedTopic="z" CrossReferenceType="SeeAlso"/>
</Topic>
</Topic>
<Topic Self="zoo" Name="Zoo" SortOrder="Aardvark">
<Topic Self="leaf" Name="Leaf"/>
</Topic>
</Index>"#,
r#"<PageReference Self="r1" ReferencedTopic="z" PageReferenceType="CurrentPage"/>
<PageReference Self="r2" ReferencedTopic="z" PageReferenceType="ForNextNParagraphs" PageReferenceLimit="4"/>
<PageReference Self="r3" ReferencedTopic="b" PageReferenceType="CurrentPage"/>
<PageReference Self="r4" ReferencedTopic="empty" PageReferenceType="SuppressPageNumbers"/>
<PageReference Self="r5" ReferencedTopic="child" PageReferenceType="CurrentPage"/>
<PageReference Self="r6" ReferencedTopic="leaf" PageReferenceType="CurrentPage"/>"#,
);
doc.check().unwrap();
let refs = references(&doc);
assert_eq!(refs.len(), 8);
for r in [
reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage),
reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(4)),
reference(&["Beta"], &[], IndexRange::CurrentPage),
reference(&["Empty"], &[], IndexRange::SuppressPageRange),
reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::CurrentPage),
reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::See("Beta".into())),
reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::SeeAlso("Zulu".into())),
reference(&["Zoo", "Leaf"], &["Aardvark"], IndexRange::CurrentPage),
] {
assert!(refs.contains(&r), "missing {r:?} in {refs:?}");
}
let story = doc.stories.values().next().unwrap();
assert_eq!(story.text.replace(designcraft_doc::INDEX_MARK, ""), "Source");
}
#[test]
fn exports_sort_keys_as_topic_attributes_without_changing_names() {
let doc = native(vec![
reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage),
reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(2)),
reference(&["Beta"], &[], IndexRange::CurrentPage),
reference(&["Empty"], &[""], IndexRange::CurrentPage),
reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::CurrentPage),
reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())),
reference(&["Related"], &["Azure"], IndexRange::SeeAlso("Zulu".into())),
reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())),
reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Zulu".into())),
]);
let index = exported_index(&doc);
assert_eq!(index.find_all("Topic").count(), 6, "repeated references and cross-reference targets share their topic");
assert_eq!(topic(&index, &["Zulu"]).get("SortOrder"), Some("Alpha"));
assert_eq!(topic(&index, &["Beta"]).get("SortOrder"), Some(""));
assert_eq!(topic(&index, &["Empty"]).get("SortOrder"), Some(""));
assert_eq!(topic(&index, &["Parent"]).get("SortOrder"), Some(""));
assert_eq!(topic(&index, &["Parent", "É & <leaf>"]).get("SortOrder"), Some(" A&<é> "));
for (name, key, kind) in [("Alias", "Able", "See"), ("Related", "Azure", "SeeAlso")] {
for path in [&[name][..], &["Parent", name][..]] {
let t = topic(&index, path);
assert_eq!(t.get("SortOrder"), Some(key));
let cross_ref = t.find("CrossReference").unwrap();
assert_eq!(cross_ref.get("CrossReferenceType"), Some(kind));
assert_eq!(cross_ref.get("ReferencedTopic"), topic(&index, &["Zulu"]).get("Self"));
}
}
}
/// These references all belong to the same page. Fix the page resolution to isolate index
/// ordering from fonts/composition, and use the real index generator for its text and headings.
fn generated_index(doc: &Document) -> Vec<(String, String)> {
let refs: Vec<_> = references(doc).into_iter().map(|r| Resolved { r, pages: Some((0, 0)) }).collect();
index::build(&IndexSpec { title: String::new(), ..Default::default() }, &refs, &|p| (p + 1).to_string())
}
#[test]
fn round_trip_preserves_generated_index_order_and_headings() {
let doc = native(vec![
reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage),
reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage),
reference(&["Beta"], &[], IndexRange::CurrentPage),
reference(&["Parent", "Zulu"], &["", "Alpha"], IndexRange::CurrentPage),
reference(&["Parent", "Beta"], &[], IndexRange::CurrentPage),
reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())),
reference(&["Related"], &["Delta"], IndexRange::SeeAlso("Beta".into())),
reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())),
reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Beta".into())),
]);
let before = generated_index(&doc);
let expected: Vec<_> = [
("Index Section Head", "A"),
("Index Level 1", "Alias See Zulu"),
("Index Level 1", "Zulu 1"),
("Index Section Head", "B"),
("Index Level 1", "Beta 1"),
("Index Section Head", "D"),
("Index Level 1", "Related 1. See also Beta"),
("Index Section Head", "P"),
("Index Level 1", "Parent"),
("Index Level 2", "Alias See Zulu"),
("Index Level 2", "Zulu 1"),
("Index Level 2", "Related 1. See also Beta"),
("Index Level 2", "Beta 1"),
]
.into_iter()
.map(|(style, text)| (style.to_string(), text.to_string()))
.collect();
assert_eq!(before, expected);
let back = import_idml(&export_idml(&doc)).unwrap();
back.check().unwrap();
assert_eq!(generated_index(&back), before);
assert_eq!(back.stories.values().next().unwrap().text.replace(designcraft_doc::INDEX_MARK, ""), "Source");
}
#[test]
fn conflicting_sort_keys_keep_the_first_serialized_value_including_defaults() {
fn check(first: &[&str], second: &[&str], expected: &str) {
let doc = native(vec![reference(&["Shared"], first, IndexRange::CurrentPage), reference(&["Shared"], second, IndexRange::CurrentPage)]);
let index = exported_index(&doc);
assert_eq!(index.find_all("Topic").count(), 1, "conflicts do not manufacture topics");
assert_eq!(topic(&index, &["Shared"]).get("SortOrder"), Some(expected));
let back = import_idml(&export_idml(&doc)).unwrap();
let refs = references(&back);
assert_eq!(refs.len(), 2);
for r in refs {
assert_eq!(r.sort.first().map_or("", String::as_str), expected);
}
assert_eq!(references(&doc)[0].sort, strings(first), "export does not edit native references");
assert_eq!(references(&doc)[1].sort, strings(second));
}
check(&["Alpha"], &["Omega"], "Alpha");
check(&["Omega"], &["Alpha"], "Omega");
check(&[], &["Alpha"], "");
check(&[""], &["Alpha"], "");
check(&["Alpha"], &[], "Alpha");
check(&["Alpha"], &[""], "Alpha");
}
#[test]
fn shared_ancestors_and_identical_leaf_names_keep_keys_by_full_path() {
let doc = native(vec![
reference(&["One", "Zulu"], &["Alpha", "First"], IndexRange::CurrentPage),
reference(&["One", "Zulu"], &["Beta", "Second"], IndexRange::CurrentPage),
reference(&["One", "Beta"], &["Gamma", "Third"], IndexRange::CurrentPage),
reference(&["Two", "Zulu"], &["Delta", "Fourth"], IndexRange::CurrentPage),
]);
let index = exported_index(&doc);
assert_eq!(topic(&index, &["One"]).get("SortOrder"), Some("Alpha"));
assert_eq!(topic(&index, &["One", "Zulu"]).get("SortOrder"), Some("First"));
assert_eq!(topic(&index, &["One", "Beta"]).get("SortOrder"), Some("Third"));
assert_eq!(topic(&index, &["Two"]).get("SortOrder"), Some("Delta"));
assert_eq!(topic(&index, &["Two", "Zulu"]).get("SortOrder"), Some("Fourth"));
}
+115 -2
View File
@@ -55,10 +55,27 @@ impl Remote {
Err(last)
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
fn prepare_connection(&mut self) -> std::io::Result<()> {
if let Some((reader, writer)) = self.conn.take() {
writer.set_nonblocking(true)?;
let probe = writer.peek(&mut [0]);
writer.set_nonblocking(false)?;
let live = match probe {
Ok(0) => false,
Ok(_) => true,
Err(e) => e.kind() == std::io::ErrorKind::WouldBlock,
};
if live {
self.conn = Some((reader, writer));
}
}
if self.conn.is_none() {
self.reconnect()?;
}
Ok(())
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
let Some((reader, writer)) = self.conn.as_mut() else {
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
};
@@ -78,6 +95,10 @@ impl Backend for Remote {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
if let Err(e) = self.prepare_connection() {
self.conn = None;
return Err(format!("DesignCraft app at {} is not reachable: {e}; nothing was sent", self.addr));
}
let reply = match self.roundtrip(&line) {
Ok(r) => r,
Err(e) => {
@@ -107,6 +128,98 @@ mod tests {
use super::*;
use std::net::TcpListener;
fn closed_remote() -> (Remote, String) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut remote = Remote::connect(&addr).unwrap();
let (mut stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
let server = std::thread::spawn(move || {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
assert!(reader.read_line(&mut line).unwrap() > 0);
let request: Value = serde_json::from_str(&line).unwrap();
assert_eq!(request["method"], "document.inspect");
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": null})).unwrap();
stream.shutdown(std::net::Shutdown::Both).unwrap();
drop(listener);
});
assert_eq!(remote.call("document.inspect", json!({})), Ok(Value::Null));
server.join().unwrap();
let stream = &remote.conn.as_ref().unwrap().1;
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
assert_eq!(stream.peek(&mut [0]).unwrap(), 0, "FIN must be observable before the next call");
(remote, addr)
}
#[test]
fn remote_executes_once_when_app_restarts_between_calls() {
let (mut remote, addr) = closed_remote();
let listener = TcpListener::bind(&addr).unwrap();
listener.set_nonblocking(true).unwrap();
let server = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if std::time::Instant::now() >= deadline {
return 0;
}
std::thread::yield_now();
}
Err(e) => panic!("accept failed: {e}"),
}
};
// The listener polls accept; the connection blocks while waiting for its request.
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut applied = 0;
loop {
let mut line = String::new();
assert!(reader.read_line(&mut line).unwrap() > 0);
let request: Value = serde_json::from_str(&line).unwrap();
match request["method"].as_str().unwrap() {
"engine.execute" => {
assert_eq!(request["params"]["command"], "frame.create");
applied += 1;
}
"document.inspect" => {}
method => panic!("unexpected method: {method}"),
}
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
if request["method"] == "document.inspect" {
return applied;
}
}
});
let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}}));
let inspected = match &result {
Ok(_) => remote.call("document.inspect", json!({})),
Err(_) => Err("edit failed".to_string()),
};
let applied = server.join().unwrap();
assert_eq!(result, Ok(json!({"applied": 1})), "the first edit after restart must succeed");
assert_eq!(inspected, Ok(json!({"applied": 1})));
assert_eq!(applied, 1);
}
#[test]
fn remote_reports_nothing_sent_when_reconnect_has_no_listener() {
let (mut remote, _addr) = closed_remote();
let error = remote.call("engine.execute", json!({"command": "frame.create", "params": {}})).unwrap_err();
assert!(error.contains("not reachable"), "{error}");
assert!(error.contains("nothing was sent"), "{error}");
assert!(!error.contains("operation may have completed"), "{error}");
}
#[test]
fn remote_does_not_replay_when_reply_is_lost() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -149,7 +262,7 @@ mod tests {
let applied = server.join().unwrap();
assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}");
assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}");
assert!(result.unwrap_err().contains("operation may have completed"), "lost reply must report an uncertain outcome");
assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully");
}
}
+2 -2
View File
@@ -202,7 +202,7 @@ impl Headless {
let png = img.to_png();
match s(p, "path") {
Some(path) => {
std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?;
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
Ok(json!({"path": path, "width": img.width, "height": img.height}))
}
None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})),
@@ -222,7 +222,7 @@ impl Headless {
.ok_or_else(|| format!("no page {page}"))?;
let lower = path.to_ascii_lowercase();
let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?;
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?;
Ok(json!({"path": path, "width": img.width, "height": img.height}))
}
}
+1 -1
View File
@@ -480,7 +480,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let path = a.get("path").and_then(Value::as_str);
if let Some(path) = path {
let png = designcraft_engine::cmd::base64_decode(&b64);
std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?;
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
}
Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path})))
}
+15 -16
View File
@@ -630,7 +630,6 @@ impl Exporter<'_> {
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppage].x;
let page_name = doc.page_name(first + pi);
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
@@ -639,7 +638,7 @@ impl Exporter<'_> {
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(&page_name), true);
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
}
for it in &sp.items {
@@ -679,9 +678,9 @@ impl Exporter<'_> {
}
/// A spread or parent item, inside its structure tag when tagging.
fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>, on_parent: bool) {
fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>, on_parent: bool) {
if !self.opts.tagged || it.hidden || it.nonprinting {
return self.item(s, it, parent, page_name);
return self.item(s, it, parent, page);
}
let story = it.text_frame().map(|t| t.story);
let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items();
@@ -689,7 +688,7 @@ impl Exporter<'_> {
// Page furniture and decoration.
let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other };
s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None)));
self.item(s, it, parent, page_name);
self.item(s, it, parent, page);
s.end_tagged();
return;
}
@@ -700,12 +699,12 @@ impl Exporter<'_> {
self.tags.push(TagEntry::Story(sid));
}
self.tag_story = Some(sid);
self.item(s, it, parent, page_name);
self.item(s, it, parent, page);
self.tag_story = None;
return;
}
let id = s.start_tagged(ContentTag::Other);
self.item(s, it, parent, page_name);
self.item(s, it, parent, page);
s.end_tagged();
let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone());
let mut g = TagGroup::new(Tag::Figure(alt));
@@ -751,10 +750,10 @@ impl Exporter<'_> {
tree
}
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) {
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
let gf = &it.effects.gradient_feather;
if !gf.on || it.hidden || it.nonprinting {
return self.item_inner(s, it, parent, page_name);
return self.item_inner(s, it, parent, page);
}
// Gradient feather: a luminosity mask (white = opaque) over the object.
let xf = parent * it.xf;
@@ -804,11 +803,11 @@ impl Exporter<'_> {
ms.finish();
let stream = sb.finish();
s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity));
self.item_inner(s, it, parent, page_name);
self.item_inner(s, it, parent, page);
s.pop();
}
fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) {
fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
if it.hidden || it.nonprinting {
return;
}
@@ -843,7 +842,7 @@ impl Exporter<'_> {
s.push_clip_path(c, &FillRule::NonZero);
}
for c in it.children() {
self.item(s, c, xf, page_name);
self.item(s, c, xf, page);
}
if clip.is_some() {
s.pop();
@@ -863,7 +862,7 @@ impl Exporter<'_> {
if !it.children().is_empty() {
let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it);
for c in it.shown_children() {
self.item(s, c, xf, page_name);
self.item(s, c, xf, page);
}
for _ in 0..pushes {
s.pop();
@@ -921,7 +920,7 @@ impl Exporter<'_> {
}
}
Content::Text(tfr) => {
let cs = self.cache.get(doc, tfr.story, page_name);
let cs = self.cache.get(doc, tfr.story, page);
if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) {
// Type on a path: the glyphs as outlines along the path.
for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) {
@@ -966,11 +965,11 @@ impl Exporter<'_> {
if let Content::Text(tfr) = &it.content
&& let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty())
{
let cs = self.cache.get(doc, tfr.story, page_name);
let cs = self.cache.get(doc, tfr.story, page);
if let Some(ft) = cs.frame(it.id) {
for o in &ft.objects {
if let Some(obj) = st.objects.get(o.index) {
self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name);
self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page);
}
}
}
+4 -4
View File
@@ -66,7 +66,7 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter {
impl Renderer {
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) {
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
let doc = f.doc;
let e = &it.effects;
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };
@@ -95,7 +95,7 @@ impl Renderer {
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
c.set_transform(moved.view);
c.push_layer(None, None, None, None, Some(filter));
me.draw_body(c, &moved, it, bp, xf, page_name);
me.draw_body(c, &moved, it, bp, xf, page);
if grow > 0.0 && solid {
c.set_transform(moved.view * xf);
c.set_paint(peniko::Color::BLACK);
@@ -119,7 +119,7 @@ impl Renderer {
self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| {
c.set_transform(fr.view * xf);
c.push_clip_layer(bp);
me.draw_body(c, fr, it, bp, xf, page_name);
me.draw_body(c, fr, it, bp, xf, page);
// Keep the body where the blurred inset silhouette is.
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
@@ -139,7 +139,7 @@ impl Renderer {
c.pop_layer();
});
} else {
self.draw_body(ctx, f, it, bp, xf, page_name);
self.draw_body(ctx, f, it, bp, xf, page);
}
// Directional feather: one fading ramp per side, multiplied in.
let df = &e.directional_feather;
+13 -14
View File
@@ -216,7 +216,7 @@ pub struct Renderer {
resources: Resources,
glyphs: text::GlyphCache,
/// Composed stories looked up during the current render.
stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>,
stories: HashMap<(StoryId, Option<usize>), Arc<ComposedStory>>,
pub threads: u16,
pub stats: FrameStats,
}
@@ -408,7 +408,6 @@ impl Renderer {
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppage].x;
let page_name = doc.page_name(first + pi);
let pr = parent.pages[ppage].bounds();
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
@@ -419,7 +418,7 @@ impl Renderer {
continue;
}
let _ = pr;
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name));
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(first + pi));
}
}
}
@@ -431,7 +430,7 @@ impl Renderer {
}
}
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) {
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option<usize>) {
if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting {
return;
}
@@ -458,7 +457,7 @@ impl Renderer {
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(&bp);
for c in it.children() {
self.draw_item(ctx, f, c, xf, page_name);
self.draw_item(ctx, f, c, xf, page);
}
ctx.pop_layer();
if !it.stroke.is_none() {
@@ -489,11 +488,11 @@ impl Renderer {
ctx.fill_path(&c.path.to_bezpath());
ctx.pop_layer();
}
self.draw_item(ctx, f, c, xf, page_name);
self.draw_item(ctx, f, c, xf, page);
}
} else {
for c in it.shown_children() {
self.draw_item(ctx, f, c, xf, page_name);
self.draw_item(ctx, f, c, xf, page);
}
}
if layered {
@@ -520,9 +519,9 @@ impl Renderer {
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
if effects {
self.draw_item_fx(ctx, f, it, &bp, xf, page_name);
self.draw_item_fx(ctx, f, it, &bp, xf, page);
} else {
self.draw_body(ctx, f, it, &bp, xf, page_name);
self.draw_body(ctx, f, it, &bp, xf, page);
}
if layered {
ctx.pop_layer();
@@ -531,18 +530,18 @@ impl Renderer {
/// The composed story for a frame (memoised for the current render: the cache lookup
/// validates the whole thread each time).
fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
let key = (sid, page_name.map(str::to_string));
fn story(&mut self, f: &Frame, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> {
let key = (sid, page);
if let Some(cs) = self.stories.get(&key) {
return cs.clone();
}
let cs = f.cache.get(f.doc, sid, page_name);
let cs = f.cache.get(f.doc, sid, page);
self.stories.insert(key, cs.clone());
cs
}
/// Fill, content and stroke of a leaf item (no opacity, blend or effects).
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) {
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
let doc = f.doc;
// Fill.
if !it.fill.is_none() {
@@ -575,7 +574,7 @@ impl Renderer {
ctx.pop_layer();
}
Content::Text(tf) => {
let cs = self.story(f, tf.story, page_name);
let cs = self.story(f, tf.story, page);
if let Some(ft) = cs.frame(it.id) {
match &tf.options.path {
Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt),
+17 -3
View File
@@ -117,8 +117,8 @@ fn union(r: Option<Rect>, b: Rect) -> Option<Rect> {
fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs {
let mut runs: Vec<(u32, BezPath)> = Vec::new();
let mut underlines = Vec::new();
let mut strikes = Vec::new();
let mut underlines: Vec<(String, f32, Rect)> = Vec::new();
let mut strikes: Vec<(String, f32, Rect)> = Vec::new();
let mut glyphs = 0;
for g in &l.glyphs {
if !g.visible {
@@ -127,7 +127,21 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
let style = &cs.styles[g.style as usize];
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, designcraft_compose::rule_baseline(style, l, g))));
let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g));
// One bar per stretch, as the PDF export draws them: a bar per glyph left
// anti-aliased seams between the letters.
match bars.last_mut() {
Some((color, tint, last))
if *color == rule.color
&& *tint == rule.tint
&& (last.y0 - r.y0).abs() < 1e-6
&& (last.y1 - r.y1).abs() < 1e-6
&& (r.x0 - last.x1).abs() < 0.5 =>
{
last.x1 = last.x1.max(r.x1);
}
_ => bars.push((rule.color.clone(), rule.tint, r)),
}
}
}
let outline = db.outline(&g.face, g.gid);
+15 -1
View File
@@ -99,6 +99,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let chars: Vec<char> = text.chars().collect();
// Paragraphs: (style, runs of (text, format)).
let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())];
let mut started = false;
let mut fmt = CharFormat::default();
let mut para_styles: Vec<String> = Vec::new();
let mut char_styles: Vec<String> = Vec::new();
@@ -151,6 +152,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
}
"ParaStyle" => {
started = true;
let v = value.trim().to_string();
if !v.is_empty() && !para_styles.contains(&v) {
para_styles.push(v.clone());
@@ -187,7 +189,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
let style = paras.last().map(|p| p.0.clone()).unwrap_or_default();
// Header lines before any text don't start paragraphs.
if paras.len() == 1 && paras[0].1.is_empty() {
if !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) {
i += 1;
continue;
}
@@ -243,6 +245,18 @@ mod tests {
assert_eq!(import("<ASCII-WIN>\r\n<ParaStyle:Body>A<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
}
#[test]
fn tagged_text_keeps_a_leading_empty_paragraph() {
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
let mut story = Story::new(StoryId(0));
story.insert(0, "\nText");
story.paras[0].style = "Head".into();
story.paras[1].style = "Body".into();
let back = import(tagged_text(&doc, &story).as_bytes()).unwrap();
assert_eq!(back.story.text, story.text);
assert_eq!(back.story.paras, story.paras);
}
#[test]
fn tagged_text_round_trips() {
let src = "<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Head>Caf<0x00E9> \\<1\\>\r\n<ParaStyle:Body>A <CharStyle:Em>word<CharStyle:> and <cSize:18.000000>big<cSize:> text";
+4
View File
@@ -348,6 +348,10 @@ pub enum Cursor {
ResizeNeSw,
Rotate,
Pen,
/// The Pen over an open path's end point: a click continues the path.
PenContinue,
/// Over a type-on-a-path bracket: dragging it moves where the text starts or ends.
PathBracket,
Text,
Hand,
HandGrab,
+102 -6
View File
@@ -1,8 +1,9 @@
//! Pen tool (P): click for corner points, drag for smooth points, click the first point to close,
//! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in
//! InDesign.
//! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from
//! there; dragging from it first reshapes its direction lines.
use designcraft_doc::SpreadRef;
use designcraft_doc::{ItemId, SpreadRef};
use designcraft_geom::{BezPath, Point};
use serde_json::{Value, json};
@@ -19,6 +20,45 @@ pub struct PenTool {
hover: Point,
/// Smart guides in canvas coordinates, as `snap_point` returned them.
guides: Vec<Overlay>,
/// Continuing an existing path: the subpath the new anchors go on.
subpath: Option<usize>,
/// The press was on the continued path's end point: a drag reshapes its direction lines
/// instead of adding an anchor.
editing_end: bool,
/// The press on the end point has been dragged.
end_moved: bool,
}
/// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last
/// anchor). The selection is checked first, then the frontmost item under the pointer.
fn endpoint_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize, bool)> {
let tol = cx.tol(6.0);
let mut ids = cx.selection.items.clone();
if let Some((_, id)) = cx.hit(p)
&& !ids.contains(&id)
{
ids.push(id);
}
for id in ids {
let Some(it) = cx.doc.item(id) else { continue };
if it.locked || it.is_group() || !matches!(it.content, designcraft_doc::Content::Unassigned) {
continue;
}
let Some(xf) = cx.item_canvas_xf(id).map(|m| m * it.xf) else { continue };
for (si, sp) in it.path.subpaths.iter().enumerate() {
if sp.closed {
continue;
}
let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue };
if (xf * last.p - p).hypot() <= tol {
return Some((id, si, true));
}
if (xf * first.p - p).hypot() <= tol {
return Some((id, si, false));
}
}
}
None
}
fn anchor_json(a: &(Point, Point, Point)) -> Value {
@@ -32,9 +72,35 @@ impl PenTool {
self.anchors.clear();
self.dragging = false;
self.guides.clear();
self.subpath = None;
self.editing_end = false;
self.end_moved = false;
vec![]
}
/// Start continuing subpath `si` of `id` from its last (`at_end`) or first anchor: the anchors
/// so far are its other end and the end point, in spread coordinates.
fn continue_from(&mut self, cx: &ToolContext, id: ItemId, si: usize, at_end: bool) -> Vec<Action> {
let (Some(it), Some(loc)) = (cx.doc.item(id), cx.doc.find(id)) else { return vec![] };
let Some(sp) = it.path.subpaths.get(si) else { return vec![] };
let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { return vec![] };
let xf = cx.doc.parent_xf(&loc) * it.xf;
let at = |a: &designcraft_geom::Anchor, flip: bool| {
let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) };
(xf * a.p, xf * i, xf * o)
};
// From the first anchor the path is reversed first, so the new anchors follow on.
let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) };
self.path = Some(id.0);
self.subpath = Some(si);
self.spread = Some(loc.spread);
self.anchors = vec![other, end];
self.editing_end = true;
self.end_moved = false;
self.dragging = true;
if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] }
}
/// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas.
fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point {
if shift && let Some(last) = self.anchors.last() {
@@ -53,7 +119,13 @@ impl PenTool {
let anchors: Vec<Value> = self.anchors.iter().map(anchor_json).collect();
vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))]
}
Some(id) => vec![Action::Exec("path.appendAnchor".into(), json!({"id": id, "anchor": anchor_json(last)}))],
Some(id) => {
let mut p = json!({"id": id, "anchor": anchor_json(last)});
if let Some(si) = self.subpath {
p["subpath"] = json!(si);
}
vec![Action::Exec("path.appendAnchor".into(), p)]
}
None => vec![],
}
}
@@ -93,12 +165,24 @@ impl Tool for PenTool {
&& (first.0 - sp).hypot() <= cx.tol(6.0)
{
let out = match self.path {
Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))],
Some(id) => {
let mut p = json!({"id": id});
if let Some(si) = self.subpath {
p["subpath"] = json!(si);
}
vec![Action::Exec("path.close".into(), p)]
}
None => vec![],
};
self.finish();
return out;
}
// On an open path's end point: continue that path.
if self.anchors.is_empty()
&& let Some((id, si, at_end)) = endpoint_at(cx, ev.pos)
{
return self.continue_from(cx, id, si, at_end);
}
let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift);
if self.anchors.is_empty() {
self.spread = Some(sr);
@@ -108,6 +192,7 @@ impl Tool for PenTool {
vec![]
}
PointerKind::Drag => {
self.end_moved |= self.editing_end;
if let Some(a) = self.anchors.last_mut() {
// Smooth point: out handle follows the pointer, in handle mirrors it.
a.2 = sp;
@@ -120,6 +205,17 @@ impl Tool for PenTool {
return vec![];
}
self.dragging = false;
// The continued path's end point: a drag set its direction lines.
if std::mem::take(&mut self.editing_end) {
let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] };
if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 {
return vec![];
}
return vec![Action::Exec(
"path.setAnchor".into(),
json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}),
)];
}
self.commit_anchor()
}
_ => vec![],
@@ -165,8 +261,8 @@ impl Tool for PenTool {
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Pen
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen }
}
fn busy(&self) -> bool {
+92 -1
View File
@@ -45,6 +45,11 @@ enum Drag {
start: Point,
cur: Point,
},
/// Dragging a type-on-a-path bracket along its path (`end`: the end bracket).
Bracket {
id: ItemId,
end: bool,
},
Anchor {
id: u64,
si: usize,
@@ -58,6 +63,50 @@ enum Drag {
},
}
/// Half the length of a path-type bracket, in screen pixels.
const BRACKET_PX: f64 = 9.0;
/// A selected type-on-a-path item's path (inner space), its inner → canvas transform and its
/// brackets' arc lengths (start, end).
fn path_type_of(cx: &ToolContext, id: ItemId) -> Option<(designcraft_geom::warp::PathWarp, designcraft_geom::Affine, f64, f64)> {
let it = cx.doc.item(id)?;
let pt = it.text_frame()?.options.path.as_ref()?;
let warp = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), pt.flip);
let end = pt.end.unwrap_or(warp.length()).min(warp.length());
Some((warp, cx.item_canvas_xf(id)? * it.xf, pt.start, end))
}
/// The bracket line at arc length `s`: across the path, `BRACKET_PX` either side (canvas).
fn bracket_line(cx: &ToolContext, warp: &designcraft_geom::warp::PathWarp, xf: designcraft_geom::Affine, s: f64) -> Option<(Point, Point)> {
let (p, a) = warp.at(s)?;
let c = xf * p;
let t = xf * (p + designcraft_geom::Vec2::new(a.cos(), a.sin())) - c;
let n = designcraft_geom::Vec2::new(-t.y, t.x);
let n = n / n.hypot().max(1e-9) * cx.tol(BRACKET_PX);
Some((c - n, c + n))
}
/// The bracket of a selected type-on-a-path item under `p` (canvas): (item, whether it is the end
/// bracket).
fn bracket_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> {
let tol = cx.tol(BRACKET_PX);
cx.selection.items.iter().find_map(|id| {
let (warp, xf, start, end) = path_type_of(cx, *id)?;
if cx.doc.item(*id).is_some_and(|it| it.locked) {
return None;
}
let near = |s: f64| warp.at(s).is_some_and(|(q, _)| (xf * q - p).hypot() <= tol);
// The end bracket first: with both brackets at one point, it can still be pulled back.
if near(end) {
Some((*id, true))
} else if near(start) {
Some((*id, false))
} else {
None
}
})
}
/// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle).
pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> {
anchor_at_in(cx, &cx.selection.items, p)
@@ -444,6 +493,13 @@ impl Tool for SelectionTool {
}
PointerKind::Down => {
self.shift_release = None;
// Type on a path: a bracket moves where the text starts or ends.
if !ev.mods.shift
&& let Some((id, end)) = bracket_at(cx, p)
{
self.drag = Drag::Bracket { id, end };
return vec![Action::Begin("Move Bracket".into())];
}
if self.direct
&& let Some((id, si, ai, handle)) = anchor_at(cx, p)
{
@@ -503,6 +559,14 @@ impl Tool for SelectionTool {
}
}
PointerKind::Drag => match self.drag.clone() {
Drag::Bracket { id, end } => {
let Some((warp, xf, _, _)) = path_type_of(cx, id) else { return vec![] };
if xf.determinant().abs() < 1e-12 {
return vec![];
}
let s = warp.nearest(xf.inverse() * p);
vec![Action::Preview("type.pathOptions".into(), json!({"id": id.0, if end { "end" } else { "start" }: s}))]
}
Drag::Pending { start, hit } => {
if (p - start).hypot() < cx.tol(3.0) {
return vec![];
@@ -749,7 +813,9 @@ impl Tool for SelectionTool {
let release = self.shift_release.take();
let d = std::mem::replace(&mut self.drag, Drag::None);
match d {
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit],
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => {
vec![Action::Commit]
}
Drag::Pending { .. } => match release {
Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
None => vec![],
@@ -808,11 +874,15 @@ impl Tool for SelectionTool {
}
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
Drag::None | Drag::Bracket { .. } => brackets(cx),
_ => vec![],
}
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) {
return Cursor::PathBracket;
}
if self.direct {
return Cursor::ArrowHollow;
}
@@ -837,6 +907,27 @@ impl Tool for SelectionTool {
}
}
/// The start and end brackets of the selected type-on-a-path items, in the selection colour: a
/// line across the path with a short tick pointing at the text between them.
fn brackets(cx: &ToolContext) -> Vec<Overlay> {
let mut out = Vec::new();
for id in &cx.selection.items {
let Some((warp, xf, start, end)) = path_type_of(cx, *id) else { continue };
let color = cx.doc.item(*id).and_then(|it| cx.doc.layer(it.layer)).map_or([43, 155, 255], |l| l.color);
for (s, towards) in [(start, end.max(start + 1.0)), (end, start.min(end - 1.0))] {
let Some((a, b)) = bracket_line(cx, &warp, xf, s) else { continue };
out.push(Overlay::Line { a, b, color, dashed: false });
// The tick at the outer end points along the path towards the text.
if let (Some((p0, _)), Some((p1, _))) = (warp.at(s), warp.at(towards)) {
let dir = xf * p1 - xf * p0;
let dir = dir / dir.hypot().max(1e-9) * cx.tol(BRACKET_PX * 0.6);
out.push(Overlay::Line { a: b, b: b + dir, color, dashed: false });
}
}
}
out
}
fn handle_cursor(h: usize) -> Cursor {
match h {
0 | 4 => Cursor::ResizeNwSe,
+171
View File
@@ -988,3 +988,174 @@ fn clicks_in_a_row_edit_and_select_text() {
// Moving a little while pressed keeps the unit selected.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty());
}
#[test]
fn pen_continues_an_open_path_from_either_end_point() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = ItemId(d.alloc());
let anchors = vec![
designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)),
designcraft_geom::Anchor {
p: Point::new(200.0, 100.0),
h_in: Point::new(170.0, 80.0),
h_out: Point::new(230.0, 120.0),
kind: designcraft_geom::AnchorKind::Smooth,
},
];
let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false)));
it.stroke = Stroke::default();
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
let cx = ctx(&d, &s, &c, &l);
let off = l.xf(SpreadRef::Doc(0)).translation();
let at = |x: f64, y: f64| (x + off.x, y + off.y);
let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y);
let mut t = create("pen");
// The cursor says the end point continues the path; elsewhere it's the plain pen.
assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue);
assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen);
// Clicking the last anchor adds nothing yet; the next click appends to that path.
assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing");
t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0)));
let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0)));
let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") };
assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0)));
assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0)));
// Clicking the first anchor of the other end closes it.
let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0)));
assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]);
assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty());
// From the first anchor, the path is reversed first; a drag there sets its direction lines.
let mut t = create("pen");
let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0)));
assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]);
t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0)));
let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0)));
assert_eq!(
a,
vec![Action::Exec(
"path.setAnchor".into(),
serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]})
)]
);
// Closed paths and locked paths aren't continued.
let mut d2 = d.clone();
d2.item_mut(id).unwrap().locked = true;
let cx = ctx(&d2, &s, &c, &l);
let t = create("pen");
assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen);
}
#[test]
fn the_selection_tool_drags_a_path_type_bracket_along_its_path() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = ItemId(d.alloc());
let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new(
vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))],
false,
));
let mut it = Item::new(id, lid, Shape::GraphicLine, line);
let sid = designcraft_doc::StoryId(d.alloc());
let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() };
tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() });
it.content = designcraft_doc::Content::Text(tf);
let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default());
st.frames = vec![id];
d.stories.insert(sid, Arc::new(st));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let off = l.xf(SpreadRef::Doc(0)).translation();
let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y);
let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y);
// Not selected: no brackets.
let none = Selection::default();
assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty());
let s = Selection { items: vec![id], ..Default::default() };
let cx = ctx(&d, &s, &c, &l);
let mut t = create("selection");
// Both brackets drawn across the path at 140 and 400 (a line and a tick each).
let o = t.overlays(&cx);
let xs: Vec<f64> = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect();
assert_eq!(xs, [140.0, 400.0], "{o:?}");
assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket);
assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket);
// Drag the start bracket: its distance along the path follows the pointer, off the path too.
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]);
let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0)));
let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") };
assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0)));
assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}");
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]);
// The end bracket.
t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0)));
let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0)));
let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") };
assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}");
}
#[test]
fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let photo = ItemId(d.alloc());
let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
asset: designcraft_doc::AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
d.assets.insert(
designcraft_doc::AssetId(1),
std::sync::Arc::new(designcraft_doc::Asset {
id: designcraft_doc::AssetId(1),
name: "photo.png".into(),
mime: "image/png".into(),
link: None,
data: std::sync::Arc::new(vec![]),
pixels: Some((100, 100)),
page: 0,
}),
);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
// An unfilled text frame over part of the photo lets it show through.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap();
let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0));
d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into();
let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0));
// The photo's layer is locked: sampling it still works.
d.layers.iter_mut().for_each(|l| l.locked = true);
let s = Selection { items: vec![target], ..Default::default() };
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let off = l.xf(SpreadRef::Doc(0)).translation();
let cx = ctx(&d, &s, &c, &l);
let mut t = create("eyedropper");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}}));
assert_eq!(a, vec![sample(120.0, 130.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y));
assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame");
// A filled object is matched as before (here on a locked layer: not reachable, so nothing).
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty());
let mut d2 = d.clone();
d2.layers.iter_mut().for_each(|l| l.locked = false);
let cx = ctx(&d2, &s, &c, &l);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y));
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]);
// Vector graphics and missing assets must retain the ordinary attribute-matching path.
for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] {
std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into();
let cx = ctx(&d2, &s, &c, &l);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))], "{mime}");
}
d2.assets.clear();
let cx = ctx(&d2, &s, &c, &l);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]);
}
+45 -2
View File
@@ -1,6 +1,7 @@
//! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets
//! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply
//! its attributes to the selection.
//! its attributes to the selection, or a placed image to colour the selection (text or objects)
//! with the image's colour under the pointer.
use designcraft_doc::SpreadRef;
use designcraft_geom::{Point, Rect};
@@ -247,6 +248,43 @@ fn positive_pad(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v } else { 0.0 }
}
/// The spread and spread point under `p` when a placed raster shows there. Other graphic formats
/// still block an image behind them, but use attribute matching instead of pixel sampling.
fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> {
use designcraft_doc::{Content, Item};
fn shows(d: &designcraft_doc::Document, it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option<bool> {
if it.hidden || depth > 64 {
return None;
}
let xf = outer * it.xf;
if let Content::Group { items } = &it.content {
return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1));
}
let graphic = matches!(it.content, Content::Graphic(_));
// Unfilled frames (a text frame over the photo) let the image show through.
if !graphic && it.fill.is_none() {
return None;
}
if !designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0) {
return None;
}
Some(match &it.content {
Content::Graphic(g) => d.assets.get(&g.asset).is_some_and(|a| a.is_raster()),
_ => false,
})
}
let (sr, at) = cx.layout.spread_at(p)?;
let sp = cx.doc.spread(sr)?;
let mut items: Vec<&Item> = sp.items.iter().map(|i| &**i).collect();
items.sort_by_key(|i| cx.doc.layer_rank(i.layer));
let image = items
.into_iter()
.rev()
.filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible))
.find_map(|it| shows(cx.doc, it, designcraft_geom::Affine::IDENTITY, at, 0))?;
image.then_some((sr, at))
}
#[derive(Default)]
pub struct EyedropperTool;
@@ -258,8 +296,13 @@ impl Tool for EyedropperTool {
if ev.kind != PointerKind::Down {
return vec![];
}
let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some();
// A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing).
if selected && let Some((sr, at)) = image_under(cx, ev.pos) {
return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))];
}
let Some((_, id)) = cx.hit(ev.pos) else { return vec![] };
if cx.selection.items.is_empty() && cx.selection.text.is_none() {
if !selected {
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))];
}
vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))]
+1
View File
@@ -29,6 +29,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
designcraft-idml = { workspace = true }
zip = { workspace = true }
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
+108 -3
View File
@@ -393,7 +393,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::ResizeNwSe => C::ResizeNwSe,
Cursor::ResizeNeSw => C::ResizeNeSw,
Cursor::Rotate => C::Alias,
Cursor::Pen => C::Crosshair,
Cursor::Pen | Cursor::PenContinue => C::Crosshair,
Cursor::PathBracket => C::ResizeColumn,
Cursor::Text => C::Text,
Cursor::Hand => C::Grab,
Cursor::HandGrab => C::Grabbing,
@@ -1017,6 +1018,28 @@ fn draw_text_selection(
if range.is_empty() {
continue;
}
// Type on a path: the highlight bends with the text.
if let Some(map) = compose::PathTextMap::new(it, ft) {
for l in &ft.lines {
let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end));
if s >= e {
continue;
}
for q in compose::highlight_quads(l, s, e, false) {
let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x));
let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y));
// Short slices, each turned with the path.
let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize;
for k in 0..n {
let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64);
let poly =
[Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p)));
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
}
}
}
continue;
}
let mut quads = Vec::new();
let mut glyphs = Vec::new();
for (li, l) in ft.lines.iter().enumerate() {
@@ -1057,8 +1080,10 @@ fn draw_text_selection(
let m = a * doc.text_xf(it);
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
if blink {
let p0 = xf.to_screen(m * Point::new(x, bl - asc));
let p1 = xf.to_screen(m * Point::new(x, bl + desc));
// Type on a path: the caret stands across the path where it is along it.
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
@@ -1900,6 +1925,86 @@ mod tests {
}
}
/// A text frame holding `text`, the Type tool, and a harness stepping at 60 fps (clicks in a
/// row must land within the double-click delay).
fn typing(text: &str) -> (Harness<'static, DesignApp>, u64) {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": text})).unwrap();
let id = r["id"].as_u64().unwrap();
app.run("tool.select", json!({"tool": "type"})).unwrap();
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
(h, id)
}
/// The screen point over the first line of frame `id` where a click puts the caret at a byte
/// in `bytes`.
fn screen_at(h: &mut Harness<'_, DesignApp>, id: u64, bytes: std::ops::Range<usize>) -> Pos2 {
let mut x = 74.0;
let p = loop {
assert!(x < 400.0, "no point on the first line hits {bytes:?}");
h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [x, 80.0]})).unwrap();
if h.state().session.doc().unwrap().selection.text.is_some_and(|t| bytes.contains(&t.focus)) {
break Point::new(x, 80.0);
}
x += 1.0;
};
h.state_mut().run("edit.deselectAll", json!({})).unwrap();
let app = h.state();
let c = app.session.layout().to_canvas(SpreadRef::Doc(0), p);
Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(c)
}
fn click(h: &mut Harness<'_, DesignApp>, p: Pos2) {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
}
fn selected(h: &Harness<'_, DesignApp>) -> std::ops::Range<usize> {
h.state().session.doc().unwrap().selection.text.map(|t| t.range()).unwrap_or_default()
}
#[test]
fn clicks_in_a_row_select_word_line_paragraph_with_return_and_story() {
let text = "One two. Three four! Five six.\nSecond para here.";
let (mut h, id) = typing(text);
// Inside "Three": double-click selects the word, then line and paragraph with return.
let p = screen_at(&mut h, id, 10..14);
h.hover_at(p);
h.step();
let mut got = vec![];
for _ in 0..5 {
click(&mut h, p);
let r = selected(&h);
got.push(text[r].to_string());
}
assert_eq!(got[1..], ["Three", "One two. Three four! Five six.", "One two. Three four! Five six.\n", text], "{got:?}");
assert!(got[0].is_empty(), "one click places the caret: {got:?}");
}
#[test]
fn slow_clicks_do_not_add_up() {
let (mut h, id) = typing("One two. Three four!");
let p = screen_at(&mut h, id, 10..14);
h.hover_at(p);
h.step();
for _ in 0..3 {
click(&mut h, p);
h.run_steps(30);
}
assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h));
}
#[test]
fn rulers_open_no_units_menu_without_a_document() {
let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()));
+51 -4
View File
@@ -489,9 +489,17 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// The widest a document tab grows: a longer name is shortened so its close button and the
/// tabs after it stay in view.
const DOC_TAB_MAX_W: f32 = 260.0;
/// Close button and padding around a tab's title.
const DOC_TAB_PAD: f32 = 44.0;
pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
let max_w = DOC_TAB_MAX_W.min(bar.width());
let close_label = crate::i18n::tr(&app.ui.language, "Close");
ui.painter().rect_filled(bar, 0.0, t.tab_strip);
ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border));
let mut x = bar.min.x;
@@ -504,14 +512,28 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) {
let is_active = Some(i) == active;
// `*` = unsaved; the view-mode suffix only in Preview (InDesign shows its GPU mode there).
let suffix = if is_active && app.ui.screen_mode == crate::ScreenMode::Preview { " [Preview]" } else { "" };
let label = format!("{}{} @ {:.0}%{suffix}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title), zoom * 100.0);
let galley = crate::rtl::plain(ui.ctx(), &label, semibold(11.5), if is_active { t.text_strong } else { t.text_dim });
let w = (galley.size().x + 44.0).max(if is_active { 210.0 } else { 150.0 });
let color = if is_active { t.text_strong } else { t.text_dim };
// The name gives way first; the dirty marker and zoom stay readable.
let zoom_label = crate::rtl::plain(ui.ctx(), &format!(" @ {:.0}%{suffix}", zoom * 100.0), semibold(11.5), color);
let name = format!("{}{}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title));
let name_max = max_w - DOC_TAB_PAD - zoom_label.size().x;
let galley = crate::rtl::truncated(ui.ctx(), &name, semibold(11.5), color, name_max);
let elided = galley.elided;
let w = (galley.size().x + zoom_label.size().x + DOC_TAB_PAD).max(if is_active { 210.0 } else { 150.0 }).min(max_w);
let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0));
let resp = ui.interact(r, ui.id().with(("doctab", i)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
let resp = if elided {
resp.on_hover_ui(|ui| {
let _ = crate::rtl::label(ui, title);
})
} else {
resp
};
ui.painter().rect_filled(r, 0.0, if is_active { t.panel } else { t.tab_strip });
let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0));
let cresp = ui.interact(cr, ui.id().with(("docclose", i)), Sense::click());
cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{close_label} {title}")));
ui.painter().text(
cr.center(),
egui::Align2::CENTER_CENTER,
@@ -519,7 +541,11 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) {
egui::FontId::proportional(14.0),
if cresp.hovered() { t.text_strong } else { t.text_dim },
);
ui.painter().galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
let name_w = galley.size().x;
// Clipped too, for a window narrower than the zoom label.
let text = ui.painter().with_clip_rect(r);
text.galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
text.galley(egui::pos2(r.min.x + 32.0 + name_w, r.center().y - zoom_label.size().y / 2.0), zoom_label, Color32::WHITE);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if cresp.clicked() {
close = Some(i);
@@ -783,6 +809,27 @@ mod tests {
h.get_by_label(label).rect()
}
/// A very long document name made its tab grow past the window, hiding the close button
/// and the tabs after it (#603).
#[test]
fn long_document_names_keep_tabs_short_and_close_buttons_visible() {
let latin = "Quarterly report with a very long file name ".repeat(6);
let hebrew = "דוח רבעוני עם שם קובץ ארוך מאוד ".repeat(6);
let mut app = app();
for title in [latin.as_str(), hebrew.as_str(), "Short"] {
app.run("file.new", serde_json::json!({ "title": title })).unwrap();
}
let h = test_window::open(app, vec2(1200.0, 600.0));
for title in [latin.as_str(), hebrew.as_str()] {
let tab = rect(&h, title);
assert!(tab.width() <= super::DOC_TAB_MAX_W, "{title:?} tab is {} wide", tab.width());
let close = rect(&h, &format!("Close {title}"));
assert!(tab.contains_rect(close), "close button {close:?} outside its tab {tab:?}");
}
let (first, short) = (rect(&h, &latin), rect(&h, "Short"));
assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right");
}
#[test]
fn a_narrow_window_scrolls_the_control_bar() {
let w = 700.0;
+99 -15
View File
@@ -102,6 +102,7 @@ fn key_from(name: &str) -> Option<egui::Key> {
/// Handle one control request. Commands it runs leave the user's system clipboard alone.
pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
app.install_clipboard_guard(ctx);
let was = std::mem::replace(&mut app.in_control, true);
let out = handle_request(app, ctx, req);
app.in_control = was;
@@ -180,21 +181,28 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
mac_cmd: b("cmd") && cfg!(target_os = "macos"),
command: b("cmd"),
};
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
if let Some(t) = s("text") {
app.synthetic.push(egui::Event::Text(t.to_string()));
app.control_synthetic.push(egui::Event::Text(t.to_string()));
}
ctx.request_repaint();
ok(Value::Null)
}
"ui.move" => {
let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32);
app.synthetic.push(egui::Event::PointerMoved(pos));
app.control_synthetic.push(egui::Event::PointerMoved(pos));
ctx.request_repaint();
ok(Value::Null)
}
"ui.click" | "ui.drag" => {
let count = match p.get("count") {
None => 1,
Some(v) => match v.as_u64().filter(|n| (1..=255).contains(n)) {
Some(n) => n,
None => return err("`count` must be an integer from 1 through 255"),
},
};
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let button = match s("button") {
@@ -212,21 +220,20 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
};
let a = egui::pos2(f("x"), f("y"));
let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a };
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
app.control_synthetic.push(egui::Event::PointerMoved(a));
app.control_synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
if req.method == "ui.drag" {
// From the caller: a huge count would queue events until memory runs out.
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000);
for i in 1..=steps {
let t = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
app.control_synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
}
}
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
for _ in 1..count {
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
}
ctx.request_repaint();
ok(Value::Null)
@@ -236,7 +243,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
// Focused widgets retain the synthetic keyboard route. Document automation uses
// the explicit caret independently of the active tool, unlike ordinary typing.
if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() {
app.synthetic.push(egui::Event::Text(text.to_string()));
app.control_synthetic.push(egui::Event::Text(text.to_string()));
ctx.request_repaint();
ok(Value::Null)
} else {
@@ -317,8 +324,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
}
"ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)),
"ui.dialog.cancel" => {
app.cancel_pdf_import();
app.ui.dialog = None;
crate::dialogs::cancel(app);
ok(Value::Null)
}
"ui.resize" => {
@@ -368,6 +374,8 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.export" => wrap(app.run("app.exportPng", p.clone())),
"app.quit" => {
// Automation must not wait on a prompt: quit without asking about unsaved changes.
app.quit_without_asking = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
@@ -453,7 +461,7 @@ mod automation_contract_tests {
}
assert!(ctx.text_edit_focused());
assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"field"}))["ok"], true);
let events = std::mem::take(&mut app.synthetic);
let events = std::mem::take(&mut app.control_synthetic);
frame(&mut app, &ctx, events);
assert_eq!(app.ui.palette.as_deref(), Some("field"));
assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
@@ -528,3 +536,79 @@ mod tests {
assert!(text(&app).contains("SECRET"));
}
}
#[cfg(test)]
mod deferred_clipboard_tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
#[test]
fn synthetic_paste_keeps_mock_clipboard_private() {
let reads = Arc::new(AtomicUsize::new(0));
let observed = reads.clone();
let mut app = DesignApp::new(
designcraft_engine::Session::new(),
crate::Services {
clipboard_text: Some(Box::new(move || {
observed.fetch_add(1, Ordering::SeqCst);
Some("SYNTHETIC_CLIPBOARD_SENTINEL".to_string())
})),
..Default::default()
},
);
app.run("file.new", json!({})).unwrap();
let frame = app.run("frame.create", json!({"content":"text", "text":"Original", "rect":[72,72,200,100], "caret":false})).unwrap();
app.run("selection.set", json!({"ids":[frame["id"]]})).unwrap();
assert!(!app.session.wants_text());
let ctx = egui::Context::default();
let (req, _) = ControlRequest::new("ui.key", json!({"key":"V", "cmd":true}));
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==true));
assert!(!app.in_control);
for step in 0..3 {
let mut raw = egui::RawInput { time: Some(step as f64 * 0.05), ..Default::default() };
app.raw_input_hook(&mut raw);
ctx.run_ui(raw, |ui| menus::shortcuts(&mut app, ui.ctx())).textures_delta.clear();
}
let texts: Vec<String> = app.session.active().unwrap().doc.stories.values().map(|story| story.text.clone()).collect();
assert_eq!(reads.load(Ordering::SeqCst), 0, "synthetic command read mocked system clipboard; stories={texts:?}");
assert!(!texts.iter().any(|text| text.contains("SYNTHETIC_CLIPBOARD_SENTINEL")));
// Once control input is drained, an ordinary user paste still reads its clipboard.
let mut raw = egui::RawInput::default();
app.raw_input_hook(&mut raw);
app.run("edit.paste", json!({})).unwrap();
assert_eq!(reads.load(Ordering::SeqCst), 1);
}
#[test]
fn deferred_control_widget_copy_leaves_native_clipboard_output_alone() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let (req, _) = ControlRequest::new("ui.move", json!({"x":10,"y":10}));
let _ = handle(&mut app, &ctx, &req);
let mut raw = egui::RawInput::default();
app.raw_input_hook(&mut raw);
let mut private = ctx.run_ui(raw, |ui| ui.ctx().copy_text("control field".into()));
assert!(!private.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(_))));
private.textures_delta.clear();
let mut raw = egui::RawInput::default();
app.raw_input_hook(&mut raw);
let mut native = ctx.run_ui(raw, |ui| ui.ctx().copy_text("native field".into()));
assert!(native.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(text) if text=="native field")));
native.textures_delta.clear();
}
#[test]
fn control_click_rejects_unbounded_counts_before_queuing_input() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
for count in [json!(u64::MAX), json!(256), json!(0), json!(-1), json!(1.5)] {
let (req, _) = ControlRequest::new("ui.click", json!({"x":10,"y":10,"count":count}));
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==false));
assert!(app.control_synthetic.is_empty());
assert!(app.synthetic.is_empty());
assert!(!app.in_control);
}
}
}
+217 -4
View File
@@ -1656,6 +1656,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.end_row();
});
}
STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d),
id if id.starts_with("cmd:") => command_form(app, ui, &mut d),
"goToPage" => {
ui.horizontal(|ui| {
@@ -1733,14 +1734,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
Some(true) => {
let _ = confirm(app);
}
Some(false) => {
app.ui.dialog = None;
app.cancel_pdf_import();
}
Some(false) => cancel(app),
None => {}
}
}
/// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing
/// dialog puts the document back as it was.
pub fn cancel(app: &mut DesignApp) {
if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) {
app.session.cancel_interaction();
}
app.cancel_pdf_import();
}
/// Apply the open dialog.
pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) };
@@ -2078,6 +2085,9 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
"closeDocument" => {
// The document may have moved in the tab row (or gone) while the dialog was open.
let uid = d.fields.get("uid").and_then(Value::as_u64);
if d.b("quitting") {
return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard"));
}
let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid);
let Some(i) = index(app) else { return Ok(Value::Null) };
if !d.b("discard") {
@@ -2164,6 +2174,20 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
"alert" => Ok(Value::Null),
"findChange" if d.b("objectMode") => Ok(Value::Null),
"findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})),
STEP_AND_REPEAT => {
// The preview goes; the copies are made once, as one undo step.
if d.fields.contains_key(PREVIEWED) {
app.session.cancel_interaction();
}
let r = step_and_repeat_params(app, &d).and_then(|p| app.run("edit.stepAndRepeat", p));
if let Err(e) = &r {
let mut d = d.clone();
d.fields.remove(PREVIEWED);
d.fields.insert("status".into(), json!(e));
app.ui.dialog = Some(d);
}
r
}
id if id.starts_with("cmd:") => {
let cid = &id[4..];
let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params);
@@ -3607,6 +3631,120 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat";
/// The params a previewing dialog shows on the page (its interaction is open while it is set).
const PREVIEWED: &str = "_previewed";
/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a
/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows
/// the copies on the page while the dialog is open.
fn step_and_repeat_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units));
for (k, v) in [
("count", json!("1")),
("rows", json!("1")),
("columns", json!("1")),
("vertical", json!(format_measure(12.0, vu))),
("horizontal", json!(format_measure(12.0, hu))),
] {
d.fields.entry(k).or_insert(v);
}
let lang = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
crate::rtl::label(ui, egui::RichText::new(tr("Repeat")).font(semibold(12.0)));
egui::Grid::new("sr_repeat").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
let grid = d.b("grid");
let fields: &[(&str, &str)] = if grid { &[("rows", "Rows:"), ("columns", "Columns:")] } else { &[("count", "Count:")] };
for (k, l) in fields {
crate::rtl::label(ui, tr(l));
text_field(ui, d, k, 70.0);
ui.end_row();
}
crate::rtl::label(ui, "");
check(ui, d, "grid", &tr("Create as a grid"));
ui.end_row();
});
ui.add_space(10.0);
crate::rtl::label(ui, egui::RichText::new(tr("Offset")).font(semibold(12.0)));
ui.horizontal(|ui| {
for (k, l) in [("vertical", "Vertical:"), ("horizontal", "Horizontal:")] {
crate::rtl::label(ui, tr(l));
text_field(ui, d, k, 80.0);
ui.add_space(12.0);
}
});
});
ui.add_space(32.0);
check(ui, d, "preview", &tr("Preview"));
});
step_and_repeat_preview(app, d);
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
}
}
/// `edit.stepAndRepeat`'s params from the dialog: whole counts, offsets in the ruler units
/// (or any unit typed: "-0.75 in", "3p"). Empty fields are 1 copy and 1p0.
fn step_and_repeat_params(app: &DesignApp, d: &Dialog) -> Result<Value, String> {
let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units));
let invalid = |s: &str| format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), s.trim());
// Fields not shown yet (a dialog confirmed over the control channel) take the defaults.
let whole = |k: &str| {
let s = d.s(k);
if s.trim().is_empty() { Ok(1) } else { s.trim().parse::<u64>().map_err(|_| invalid(&s)) }
};
let measure = |k: &str, u: Unit| {
let s = d.s(k);
if s.trim().is_empty() {
return Ok(12.0);
}
parse_measure(&s, u).ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(&s))
};
let (dx, dy) = (measure("horizontal", hu)?, measure("vertical", vu)?);
Ok(if d.b("grid") {
json!({"rows": whole("rows")?, "columns": whole("columns")?, "dx": dx, "dy": dy})
} else {
json!({"count": whole("count")?, "dx": dx, "dy": dy})
})
}
/// Preview on: the copies show on the page (redone when a field changes); off: they go.
fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) {
let shown = d.fields.get(PREVIEWED).cloned();
if !d.b("preview") {
if shown.is_some() {
app.session.cancel_interaction();
d.fields.remove(PREVIEWED);
d.fields.remove("status");
}
return;
}
let params = match step_and_repeat_params(app, d) {
Ok(p) => p,
Err(e) => {
d.fields.insert("status".into(), json!(e));
return;
}
};
if shown.as_ref() == Some(&params) {
return;
}
if shown.is_none()
&& let Err(e) = app.session.begin_interaction("Step and Repeat")
{
d.fields.insert("status".into(), json!(e.to_string()));
return;
}
match app.session.preview("edit.stepAndRepeat", params.clone()) {
Ok(()) => d.fields.remove("status"),
Err(e) => d.fields.insert("status".into(), json!(e.to_string())),
};
d.fields.insert(PREVIEWED.into(), params);
}
/// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on
/// the right) or a text anchor, with a cross-reference format.
fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
@@ -3793,6 +3931,81 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
mod tests {
use super::*;
#[test]
fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [72, 600, 540, 640]})).unwrap()["id"].as_u64().unwrap();
app.run("selection.set", json!({"ids": [id]})).unwrap();
let open = |app: &mut DesignApp| {
crate::menus::activate(app, "edit.stepAndRepeat", &Value::Null);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(STEP_AND_REPEAT));
};
let set = |app: &mut DesignApp, k: &str, v: Value| {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
};
let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len();
let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len();
let (n0, u0) = (items(&app), undo(&app));
let ctx = dialog_context("en");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0));
open(&mut app);
dialog_frame(&mut app, &ctx, screen, vec![]);
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
for expected in ["Step and Repeat", "Repeat", "Count:", "Create as a grid", "Offset", "Vertical:", "Horizontal:", "Preview", "OK", "Cancel"] {
visible_label(&labels, screen, expected);
}
// Preview: the copies show, and change with the fields, without an undo step.
set(&mut app, "count", json!("5"));
set(&mut app, "vertical", json!("-0.75 in"));
set(&mut app, "horizontal", json!("0"));
set(&mut app, "preview", json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!((items(&app), undo(&app)), (n0 + 5, u0));
set(&mut app, "count", json!("3"));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!(items(&app), n0 + 3);
set(&mut app, "preview", json!(false));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!(items(&app), n0, "Preview off takes them away");
set(&mut app, "preview", json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!(items(&app), n0 + 3);
// Cancel puts the document back.
cancel(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!((items(&app), undo(&app)), (n0, u0));
// OK after a preview makes the copies once, as one undo step, 0.75 in apart.
app.run("selection.set", json!({"ids": [id]})).unwrap();
open(&mut app);
set(&mut app, "count", json!("2"));
set(&mut app, "vertical", json!("-0.75 in"));
set(&mut app, "horizontal", json!("0"));
set(&mut app, "preview", json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
let r = confirm(&mut app).unwrap();
assert_eq!((items(&app), undo(&app)), (n0 + 2, u0 + 1));
let last = r["ids"][1].as_u64().unwrap();
let top = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(last)).unwrap().bounds().min_y();
assert!((top - (600.0 - 108.0)).abs() < 1e-6, "{top}");
// Create as a grid: Rows × Columns cells, the original being one.
app.run("selection.set", json!({"ids": [id]})).unwrap();
open(&mut app);
set(&mut app, "grid", json!(true));
set(&mut app, "rows", json!("2"));
set(&mut app, "columns", json!("3"));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
visible_label(&labels, screen, "Rows:");
visible_label(&labels, screen, "Columns:");
assert_eq!(confirm(&mut app).unwrap()["created"], 5);
// A value that isn't a measure keeps the dialog open and says so.
open(&mut app);
set(&mut app, "vertical", json!("up"));
assert!(confirm(&mut app).is_err());
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.s("status"), "Invalid value: up");
}
fn dialog_context(language: &str) -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, language);
+12
View File
@@ -90,7 +90,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
x += w;
}
let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0));
let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click());
if menu.hovered() {
ui.painter().rect_filled(menu_r, 3.0, t.hover);
}
icons::paint(ui.painter(), menu_r, "menu", t.icon);
let tab = app.ui.dock_tab.clone();
egui::Popup::menu(&menu).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if tab == "pages" {
panels::pages::panel_menu(app, ui);
ui.separator();
}
crate::panel_docking::panel_menu(app, ui, &tab);
});
egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() {
"pages" => panels::pages::show(app, ui),
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more