Compare commits

..

No commits in common. "main" and "release" have entirely different histories.

143 changed files with 1111 additions and 16613 deletions

7
Cargo.lock generated
View File

@ -889,7 +889,6 @@ dependencies = [
name = "designcraft"
version = "0.7.1"
dependencies = [
"arboard",
"designcraft-engine",
"designcraft-ui-egui",
"eframe",
@ -946,7 +945,6 @@ dependencies = [
"regex",
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-segmentation",
]
[[package]]
@ -991,7 +989,6 @@ dependencies = [
"serde_json",
"tempfile",
"thiserror 2.0.21",
"unicode-segmentation",
"wasm-bindgen-test",
"zip",
]
@ -1159,7 +1156,6 @@ dependencies = [
"regex",
"serde",
"serde_json",
"tempfile",
"toml",
"unicode-bidi",
"unicode-bidi-mirroring",
@ -5520,7 +5516,8 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
"ahash",
"android-activity",

View File

@ -97,7 +97,3 @@ 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" }

View File

@ -106,7 +106,6 @@ 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
```

View File

@ -1,8 +1,8 @@
# DesignCraft roadmap
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
format + IDML), scriptable by agents (MCP), and available on the web.
@ -25,14 +25,15 @@ format + IDML), scriptable by agents (MCP), and available on the web.
| Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing |
**Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in
[docs/roadmap.md](docs/roadmap.md#alpha-gate). Text threads by hand (click a port, then click or
draw a frame; thread and unthread commands), but Primary Text Frame autoflow adds pages users
can't delete (#165). Everything else in the gate works end to end on macOS (document setup,
styles, images, object editing, PDF export, save and reopen), and ready for real work (~47%) is
above the alpha bar, so the Primary Text Frame page fixes are the whole distance to alpha.
Beyond that, beta needs INDD opening, IDML proven on real files, composition compared with
InDesign's, and basic editing fixed on Windows and Linux. Details:
[docs/target-app-parity.md](docs/target-app-parity.md).
[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to
another: the in and out ports are drawn, but clicking the out port does nothing and there is no
thread or unthread command. Long text flows only through autoflow on Place and Primary Text
Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything
else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF
export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual
threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on
real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux.
Details: [docs/target-app-parity.md](docs/target-app-parity.md).
## By dimension
@ -105,7 +106,7 @@ Also shipped: Italian (85%), Ukrainian (83%).
Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in
[docs/gaps.md](docs/gaps.md).
1. **Alpha gate:** Primary Text Frame page fixes: autoflow adds pages users can't delete (#165) (5–7 h)
1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h)
2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h)
3. CI running `cargo xtask ci` on pull requests (4–8 h)
4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h)
@ -128,37 +129,6 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
frame just added; click the linked frame or double-click a port to break the thread.
`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.
@ -201,7 +171,6 @@ Newest first.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) |
| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md |
| 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md |

View File

@ -38,37 +38,6 @@ 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!(
@ -312,7 +281,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() };
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?;
std::fs::write(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(())
}
@ -366,10 +335,6 @@ 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}`")),
}

View File

@ -87,21 +87,6 @@ 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]
@ -137,35 +122,3 @@ 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());
}

View File

@ -25,13 +25,10 @@ winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
# The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs.
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 }

View File

@ -64,33 +64,10 @@ 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()
@ -209,8 +186,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]| 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())),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
}
}
@ -300,15 +276,6 @@ 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,
@ -386,168 +353,3 @@ 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();
}
}
}

View File

@ -40,8 +40,6 @@ 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,
@ -52,11 +50,10 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&quit,
&PredefinedMenuItem::quit(None),
]);
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);

View File

@ -22,19 +22,7 @@ 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).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
}
}
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir)
}
fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
@ -80,29 +68,4 @@ 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);
}
}

View File

@ -104,12 +104,9 @@ 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, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) {
for s in &mut self.stops {
s.offset = 1.0 - s.offset;
s.midpoint = m;
}
}
pub fn sort(&mut self) {
@ -222,24 +219,8 @@ 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}");
}
}
}

View File

@ -10,7 +10,6 @@ workspace = true
[dependencies]
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi-mirroring = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }

View File

@ -38,9 +38,9 @@ use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret};
pub use crate::notes::{find_note, hit_note, note_caret};
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell, hit_cell_with};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell};
/// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]).
#[derive(Clone, Debug, PartialEq)]
@ -197,20 +197,6 @@ pub struct Line {
pub hj: u8,
/// The paragraph's Keep Options couldn't be honoured here (Highlight Keep Violations).
pub keep_violation: bool,
/// The drop cap a paragraph's first line starts with (its first glyphs, set down to a later
/// line's baseline).
pub drop_cap: Option<DropCapBox>,
}
/// Where a drop cap sits (frame inner space).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DropCapBox {
/// Story bytes from its line's start up to here are the drop cap's.
pub end: usize,
/// Its baseline.
pub baseline: f64,
/// The area it covers: from its line's top down to the text below its baseline.
pub rect: Rect,
}
/// A paragraph rule or shading rectangle in frame inner space.
@ -330,8 +316,7 @@ pub struct FrameSpec {
impl FrameSpec {
pub fn columns(&self) -> Vec<Rect> {
let a = self.area;
// Primary frames inherit the document limit (216), beyond the interactive frame editor's 40.
let n = self.opts.columns.clamp(1, 216);
let n = self.opts.columns.max(1);
match self.opts.columns_kind {
designcraft_doc::ColumnsKind::FixedWidth if self.opts.column_width > 0.0 => (0..n)
.map(|i| {
@ -432,8 +417,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
path: None,
..tf.options.clone()
};
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)
(Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts)
}
None => (item.text_area(), tf.options.clone()),
};
@ -667,7 +651,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub);
}
let sub_objects = sub.objects.clone();
let drop_end = drop_cap_end(&story.text, prange.clone(), &pp, sub.vertical);
let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts };
let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback };
let mut sp = shape::shape_para(
@ -683,22 +666,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&pp.nested_styles,
&pp.grep_styles,
&[],
(drop_end, &pp.drop_cap_style),
);
// Drop cap: the size and indent come from the text beside it (`base_*` when there's none).
let base_leading = match base_chars.leading {
designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading,
designcraft_doc::Leading::Points(v) => v,
};
let base_cap = {
let face = db.face(&base_chars.font_family, &base_chars.font_style);
face.cap_height * base_chars.size / face.upem * base_chars.v_scale
};
let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1);
// Align Left Edge (left-to-right paragraphs).
let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight;
// Tab stops for the drop cap, with the start-side indent (the right one, right to left).
let drop_tabs = (&pp.tabs[..], if pp.direction == designcraft_doc::TextDirection::RightToLeft { pp.right_indent } else { pp.left_indent });
if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() {
// Nested line styles: find where the first lines end in this column, restyle them, and
// look again (the style changes the widths) until the lines settle.
@ -708,17 +676,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let spacing = spacing_for(&pp, base_chars.size);
let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new();
for _ in 0..3 {
// The same drop cap, spacing, hyphenation and breaker as the layout below.
let width = |j: usize| (col.width() - pp.left_indent - pp.right_indent - if j == 0 { pp.first_line_indent } else { 0.0 }).max(1.0);
// The same spacing, hyphenation and breaker as the layout below.
let mut gl = sp.glyphs.clone();
let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs);
let width = |j: usize| {
let ind = match &dc {
Some(dc) if j < dc.lines => dc.indent(),
_ if j == 0 => pp.first_line_indent,
_ => 0.0,
};
(col.width() - pp.left_indent - pp.right_indent - ind).max(1.0)
};
apply_desired_spacing(&mut gl, &pp);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
@ -764,46 +724,30 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&pp.nested_styles,
&pp.grep_styles,
&lines,
(drop_end, &pp.drop_cap_style),
);
}
}
// Rules in Text Color: the colour of the paragraph's first character (rule above) and last
// character (rule below).
let text_color = |g: Option<&Glyph>| {
g.and_then(|g| table.styles.get(g.style as usize))
.map_or((base_chars.fill.clone(), base_chars.fill_tint), |s| (s.fill.clone(), s.fill_tint))
};
let (above, below) = (text_color(sp.glyphs.iter().find(|g| g.len > 0)), text_color(sp.glyphs.iter().rev().find(|g| g.len > 0)));
let rule_color = |r: &designcraft_doc::Rule, text: &(String, f32)| {
if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) }
};
let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs);
// List labels take the default super/subscript settings.
let label_env = shape::TypeEnv { adv: Default::default(), ..env };
let list_label = match pp.list_type {
match pp.list_type {
// An empty paragraph has no bullet or number, and the numbering carries on past it.
designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => None,
designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => {}
designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => {
// A named list: carries on past other paragraphs (and from earlier stories).
let n = named_numbers.get(pi).copied().flatten().unwrap_or(1);
Some(pp.number_label(n))
let label = pp.number_label(n);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
}
designcraft_doc::ListType::Numbers => {
list_counter = pp.start_at.map_or(list_counter.saturating_add(1), |s| s.max(1));
Some(pp.number_label(list_counter))
let label = pp.number_label(list_counter);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
}
designcraft_doc::ListType::Bullets => Some(pp.bullet_label()),
designcraft_doc::ListType::None => {
list_counter = 0;
None
}
};
if let Some(label) = list_label {
let n = prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
for g in sp.glyphs.iter_mut().take(n) {
g.list_label = true;
designcraft_doc::ListType::Bullets => {
let label = pp.bullet_label();
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
}
designcraft_doc::ListType::None => list_counter = 0,
}
if pi == 0
&& let Some(label) = &opts.label
@ -820,6 +764,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
apply_desired_spacing(&mut glyphs, &pp);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let base_size = base_chars.size;
let base_leading = match base_chars.leading {
designcraft_doc::Leading::Auto => base_size * pp.auto_leading,
designcraft_doc::Leading::Points(v) => v,
};
let spacing = spacing_for(&pp, base_size);
// A paragraph set across the columns moves on to the next frame where others move on to
// the next column.
@ -919,7 +867,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut col_first_line = 0usize;
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
loop {
if cur.fi >= frames.len() {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
@ -940,22 +887,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col };
let col = cur.split_rect(col);
// Estimate slots for the breaker with the paragraph's base leading.
let est_asc = base_chars.size * 0.75;
let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 };
let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp);
let est_jump = cur.jump(f, base_leading, est_asc, est_tops);
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp);
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let width = |j: usize| -> f64 {
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
// Beside the drop cap (in the column the paragraph starts in), its width; else the
// first-line indent.
let ind = pp.left_indent
+ pp.right_indent
+ match &drop_cap {
Some(dc) if line_no + j < dc.lines && col_first_line == 0 => dc.indent(),
_ if line_no + j == 0 => pp.first_line_indent,
_ => 0.0,
};
let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
(x1 - x0 - ind).max(1.0)
};
let rest = &glyphs[g0..];
@ -977,36 +913,39 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
for (k, b) in breaks.iter().enumerate() {
let (s, e) = (g0 + b.start, g0 + b.end);
let line_glyphs = &glyphs[s..e.max(s)];
let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let reference = cjk_line_reference(line_glyphs);
// Baseline grid: the next grid line at or below.
let grid = f.grid.filter(|&(_, inc)| {
inc > 0.0 && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
});
let snap = |y: f64| grid.map_or(y, |(g_start, inc)| g_start + ((y - g_start) / inc - 1e-6).ceil() * inc);
let mut baseline = cur.next_baseline(f, col, lead, asc, tops, &pp);
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
if cur.last_baseline.is_some() {
baseline += cur.last_reference - reference;
}
baseline = snap(baseline);
// Wrap: push the line down to the first position with a slot (back on the grid).
// Baseline grid.
if let Some((g_start, inc)) = f.grid
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
&& inc > 0.0
{
let n = ((baseline - g_start) / inc - 1e-6).ceil();
baseline = g_start + n * inc;
}
// Wrap: push the line down until a slot exists.
let (mut x0, mut x1) = (col.x0, col.x1);
if !f.exclusions.is_empty() {
let jump = cur.jump(f, lead, asc, tops);
let mut tries = 0;
loop {
let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size) else {
// No room in this column.
baseline = baseline.max(col.y1) + 1.0;
break;
};
let pushed = nb > baseline;
(baseline, x0, x1) = (nb, a, z);
tries += 1;
if !pushed || grid.is_none() || tries > 4000 {
break;
match free_slot(f, col, baseline - asc, baseline + desc, base_size) {
Some((a, b)) => {
x0 = a;
x1 = b;
break;
}
None => {
baseline += 1.0;
tries += 1;
if baseline > col.y1 || tries > 4000 {
break;
}
}
}
baseline = snap(baseline);
}
}
let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line;
@ -1082,15 +1021,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
moved = true;
break;
}
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent);
let (ind_l, ind_r) = match beside {
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
Some(w) => (pp.left_indent + w, pp.right_indent),
None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
};
let ind_l = pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 };
let lx0 = x0 + ind_l;
let lx1 = x1 - ind_r;
let lx1 = x1 - pp.right_indent;
let last = k + 1 == breaks.len();
// The break character that ends this line, if any (it is the line's last glyph).
let brk = if b.forced && e > s {
@ -1104,30 +1037,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last;
let (mut placed, end_x, ratio) =
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info);
// Warichu runs before ruby so a reading is placed over the stacked note, and before
// the trimmed spaces and the drop cap join the line, so neither is stacked.
// Warichu runs before ruby so a reading is placed over the stacked note.
let end_x = end_x + warichu::place(&styles_tab, &mut placed);
// The spaces the break trimmed off the end follow the line undrawn, so the caret
// and the selection reach them; alignment and justification ignore them.
if pp.direction != designcraft_doc::TextDirection::RightToLeft && !placed.iter().any(|g| g.rtl) {
let mut x = end_x;
for g in glyphs.get(e..(g0 + b.next).min(glyphs.len())).unwrap_or(&[]).iter().filter(|g| g.is_space()) {
placed.push(PlacedGlyph { visible: false, ..place(g, x) });
x += g.adv;
}
}
let mut drop_box = None;
if line_no == 0
&& let Some(dc) = &drop_cap
{
let at = if rtl { x1 - pp.right_indent - dc.width } else { x0 + pp.left_indent - dc.lsb };
placed.splice(0..0, place_drop_cap(dc, at, rtl));
drop_box = Some(DropCapBox {
end: dc.end,
baseline: baseline + dc.drop,
rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc),
});
}
ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback);
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
@ -1159,7 +1070,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
spacing: ratio,
hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max),
keep_violation: false,
drop_cap: drop_box,
});
for k in line_notes {
notes.place(doc, k, cur.fi, cur.col, col_w, f, opts);
@ -1236,8 +1146,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
.find(|l| l.para == pi && l.first_in_para)
.map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) })
.unwrap_or((0.0, 0.0));
let (color, tint) = rule_color(r, &above);
ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint });
ft.decos.push(Deco {
rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y),
color: r.color.clone(),
tint: r.tint,
});
}
}
para_box_decos(&mut out, pi, &pp);
@ -1248,8 +1161,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let r = &pp.rule_below;
let y = l.baseline + r.offset;
let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) };
let (color, tint) = rule_color(r, &below);
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint });
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint });
}
// The end of a split block: text after it continues below its deepest sub-column, which
// are balanced first. A block that ends the story fills its sub-columns in turn.
@ -1565,9 +1477,7 @@ 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,
// 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 },
hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone },
optical: pp.optical_margin,
korean_char_breaks: pp.korean_char_breaks,
}
@ -2083,56 +1993,22 @@ impl Cursor {
self.next_frame();
self.fi = fi;
}
/// Baseline for the next line with leading `lead`, ascent `asc` and first baseline heights
/// `tops`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, tops: Tops, _pp: &ParaProps) -> f64 {
/// Baseline for the next line with leading `lead` and ascent `asc`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 {
match self.last_baseline {
Some(b) => b + lead + self.pending,
None => col.y0 + first_baseline_offset(f, lead, asc, tops),
None => {
let off = match f.opts.first_baseline {
FirstBaseline::Ascent => asc,
FirstBaseline::CapHeight => asc * 0.72,
FirstBaseline::XHeight => asc * 0.5,
FirstBaseline::Leading => lead,
FirstBaseline::Fixed => 0.0,
};
col.y0 + off.max(f.opts.first_baseline_min)
}
}
}
/// How the next line clears a Jump Object wrap: by whole leadings below the line above, or, first
/// in its column, as at the top of a frame whose top is the wrap bottom.
fn jump(&self, f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> Jump {
match self.last_baseline {
Some(_) => Jump { top: lead, step: lead },
None => Jump { top: first_baseline_offset(f, lead, asc, tops), step: 0.0 },
}
}
}
/// The frame's first baseline offset below the top of a column, for a line with leading `lead`,
/// ascent `asc` and first baseline heights `tops`.
fn first_baseline_offset(f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> f64 {
let off = match f.opts.first_baseline {
// Vertical frames measure from the ascent.
FirstBaseline::Ascent | FirstBaseline::EmboxHeight if f.vertical => asc,
FirstBaseline::CapHeight if f.vertical => asc * 0.72,
FirstBaseline::XHeight if f.vertical => asc * 0.5,
FirstBaseline::Ascent => tops.typo_ascent,
FirstBaseline::CapHeight => tops.cap,
FirstBaseline::XHeight => tops.xh,
FirstBaseline::EmboxHeight => tops.em,
FirstBaseline::Leading => lead,
FirstBaseline::Fixed => 0.0,
};
off.max(f.opts.first_baseline_min)
}
/// A line's tallest typographic ascender, cap height, x height and em box top above its baseline:
/// the first baseline offsets.
#[derive(Clone, Copy, Default)]
struct Tops {
typo_ascent: f64,
cap: f64,
xh: f64,
em: f64,
}
/// The em box top above the baseline at `size` ([`designcraft_fonts::FontFace::em_box`]).
fn em_box_top(face: &designcraft_fonts::FontFace, size: f64) -> f64 {
let em = Some(face.em_box().0 / face.upem).filter(|e| e.is_finite()).unwrap_or(0.88);
em * size
}
/// Em box relative to the baseline. Font ascender/descender proportions locate it;
@ -2175,103 +2051,24 @@ fn line_metrics(
base_size: f64,
db: &ScopedFonts<'_>,
base: &designcraft_doc::CharProps,
) -> (f64, Tops, f64, f64) {
) -> (f64, f64, f64) {
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
let face = || db.face(&base.font_family, &base.font_style);
if src.is_empty() {
let face = face();
let face = db.face(&base.font_family, &base.font_style);
let k = base_size / face.upem;
let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k, em: em_box_top(&face, base_size) };
return (face.ascent * k, tops, face.descent * k, base_leading);
return (face.ascent * k, face.descent * k, base_leading);
}
let mut asc: f64 = 0.0;
let mut tops = Tops::default();
let mut desc: f64 = 0.0;
let mut lead: f64 = 0.0;
let reference = src.iter().max_by(|a, b| a.size.total_cmp(&b.size));
let mut own_text = false;
for g in src {
let align = reference.map_or(0.0, |r| cjk_alignment_shift(g, r));
let shift = g.shift + align;
let up = shift.max(0.0);
asc = asc.max(g.ascent + up);
// A list's bullet or number doesn't raise the Ascent offset: only the line's own text does.
if !g.list_label {
own_text = true;
tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up);
// The em box sits at the unshifted baseline: a baseline shift moves only the drawn glyphs.
tops.em = tops.em.max(em_box_top(&g.face, g.size) + align.max(0.0));
}
tops.cap = tops.cap.max(g.cap + up);
tops.xh = tops.xh.max(g.xh + up);
let shift = g.shift + reference.map_or(0.0, |r| cjk_alignment_shift(g, r));
asc = asc.max(g.ascent + shift.max(0.0));
desc = desc.max(g.descent - shift.min(0.0));
lead = lead.max(g.leading);
}
if !own_text {
// Only a label (an empty list paragraph): as an empty paragraph.
let face = face();
tops.typo_ascent = face.typo_ascent * base_size / face.upem;
tops.em = em_box_top(&face, base_size);
}
(asc, tops, desc, lead)
}
/// How a line clears Jump Object wraps. The line occupies `[baseline − top, baseline]` (its leading,
/// or its first baseline offset at the top of a column) and must not overlap the wrap; it may hang its
/// descenders into the wrap. Below a wrap it moves down from its place in whole `step`s until it clears
/// the wrap bottom (`step` 0: its top lands on the wrap bottom).
#[derive(Clone, Copy, Debug)]
struct Jump {
top: f64,
step: f64,
}
/// Slack for float error when a line is tested against a Jump Object wrap's edges.
const JUMP_EPS: f64 = 1e-6;
/// The first baseline at or below `baseline` where a line with ascent `asc` and descent `desc` has a
/// free slot in `col`, and that slot; None if there is none above the column bottom.
///
/// A line under a Jump Object wrap moves below it as `jump` says. Other wraps are rectangles, so the
/// free space only grows where one in the line's band ends: the search jumps from one such bottom
/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom.
fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64) -> Option<(f64, (f64, f64))> {
let top = jump.top.max(0.0);
let mut b = baseline;
// Each step passes at least one wrap; the cap bounds hostile geometry.
for _ in 0..4000 {
let jumped = f
.exclusions
.iter()
.filter(|e| e.mode == WrapMode::JumpObject)
.map(|e| e.rect)
.filter(|r| r.x0 < col.x1 && r.x1 > col.x0 && b > r.y0 + JUMP_EPS && b - top < r.y1 - JUMP_EPS)
.fold(f64::NEG_INFINITY, |m, r| m.max(r.y1));
b = if jumped > f64::NEG_INFINITY {
if jump.step > 0.0 {
// Whole steps from the line's own place (at least one step past the line above).
baseline + ((jumped + top - baseline - JUMP_EPS / 2.0) / jump.step).ceil().max(0.0) * jump.step
} else {
jumped + top
}
} else if let Some(slot) = free_slot(f, col, b - asc, b + desc, size) {
return Some((b, slot));
} else {
let (y0, y1) = (b - asc, b + desc);
let edge = f
.exclusions
.iter()
.filter(|e| e.mode != WrapMode::JumpObject)
.map(|e| e.rect)
.filter(|r| r.y0 < y1 && r.y1 > y0)
.fold(f64::INFINITY, |m, r| m.min(r.y1));
edge + asc
};
if b.is_nan() || b > col.y1 {
return None;
}
}
None
(asc, desc, lead)
}
/// Widest free horizontal interval of `col` in the band, or None if blocked.
@ -2282,6 +2079,7 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f
let overlaps_band = r.y0 < y1 && r.y1 > y0;
match ex.mode {
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => {
let mut next = Vec::new();
for (a, b) in free {
@ -2305,17 +2103,7 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f
}
#[allow(clippy::too_many_arguments)]
fn estimate_slots(
f: &FrameSpec,
col: Rect,
first: f64,
jump: Jump,
lead: f64,
size: f64,
glyphs: &[Glyph],
pp: &ParaProps,
line_no: usize,
) -> Vec<(f64, f64)> {
fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> {
let _ = (pp, line_no);
if f.exclusions.is_empty() {
return vec![];
@ -2325,21 +2113,20 @@ fn estimate_slots(
let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000);
let mut v = Vec::with_capacity(n);
let mut b = first;
let mut jump = jump;
for _ in 0..n {
let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size) {
Some((nb, s)) => {
b = nb;
s
let mut tries = 0;
let slot = loop {
if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) {
break s;
}
None => {
b = b.max(col.y1);
(col.x0, col.x1)
b += 1.0;
tries += 1;
if b > col.y1 || tries > 4000 {
break (col.x0, col.x1);
}
};
v.push(slot);
b += lead;
jump = Jump { top: lead, step: lead };
}
v
}
@ -2442,18 +2229,6 @@ 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));
@ -2866,147 +2641,6 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
}
}
/// Drop caps are at most this many lines and characters (larger values are capped).
pub const MAX_DROP_CAP_LINES: u32 = 25;
pub const MAX_DROP_CAP_CHARS: u32 = 150;
/// Byte where paragraph `prange`'s drop cap ends: after its first `drop_cap_chars` characters
/// (code points, as InDesign counts them, spaces included), extended to the end of the grapheme
/// cluster they end in so a letter keeps its combining marks; all of them in a shorter paragraph.
/// `prange.start` when it has none: no lines or characters, or vertical text (which sets no drop
/// caps).
fn drop_cap_end(text: &str, prange: Range<usize>, pp: &ParaProps, vertical: bool) -> usize {
use unicode_segmentation::UnicodeSegmentation as _;
if vertical || pp.drop_cap_lines == 0 || pp.drop_cap_chars == 0 {
return prange.start;
}
let Some(para) = text.get(prange.clone()) else { return prange.start };
let para = para.trim_end_matches('\n');
let n = pp.drop_cap_chars.min(MAX_DROP_CAP_CHARS) as usize;
let end = para.char_indices().nth(n).map_or(para.len(), |(i, _)| i);
prange.start + para.grapheme_indices(true).map(|(i, _)| i).find(|i| *i >= end).unwrap_or(para.len())
}
/// A paragraph's drop cap: its first glyphs, enlarged to reach down `lines` lines.
struct DropCap {
glyphs: Vec<Glyph>,
/// Story byte it ends at.
end: usize,
lines: usize,
/// Its advance.
width: f64,
/// How far its origin sits left of the indent: its first letter's left side bearing with
/// Align Left Edge, else 0.
lsb: f64,
/// Its baseline below the first line's: `lines − 1` leadings of the text after it (grid steps
/// on a baseline grid), wherever line `lines` is set.
drop: f64,
}
/// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale
/// it so its cap height reaches from the first line's cap height down to line `lines`' baseline,
/// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own
/// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid
/// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Tabs inside
/// it advance to the stops of `tabs` (stops and the start-side indent; in right-to-left paragraphs
/// both measured from the column's right edge). Spaces right after it join it at its size without
/// widening it.
#[allow(clippy::too_many_arguments)]
fn split_drop_cap(
db: &ScopedFonts<'_>,
glyphs: &mut Vec<Glyph>,
end: usize,
lines: u32,
base_leading: f64,
base_cap: f64,
grid: Option<f64>,
align_left: bool,
tabs: (&[designcraft_doc::TabStop], f64),
) -> Option<DropCap> {
let n = glyphs.iter().take_while(|g| g.byte < end).count();
if n == 0 {
return None;
}
let lines = lines.clamp(1, MAX_DROP_CAP_LINES) as usize;
let mut dc: Vec<Glyph> = glyphs.drain(..n).collect();
let spaces = glyphs.iter().take_while(|g| g.is_space()).count();
let mut after: Vec<Glyph> = glyphs.drain(..spaces).collect();
let end = after.last().map_or(end, |g| g.byte.saturating_add(g.len).max(end));
let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0);
let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap));
let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) {
Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch,
_ => lead,
};
let drop = (lines - 1) as f64 * lead;
let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max);
let k = if own > 0.0 { (drop + cap) / own } else { 1.0 };
let k = if lines == 1 { k.min(1.0) } else { k };
let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 };
for g in dc.iter_mut().chain(after.iter_mut()) {
g.adv *= k;
g.dx *= k;
g.dy *= k;
g.sx *= k;
g.sy *= k;
g.ascent *= k;
g.typo_ascent *= k;
g.descent *= k;
g.cap *= k;
g.xh *= k;
g.size *= k;
g.space *= k;
}
let lsb = match dc.first() {
Some(g) if align_left => {
let ink = designcraft_geom::Shape::bounding_box(&*db.outline(g.face.get(), g.gid));
let lsb = g.dx + ink.x0 * g.sx;
if ink.width() > 0.0 && lsb.is_finite() { lsb } else { 0.0 }
}
_ => 0.0,
};
// Tabs advance to the paragraph's stops, measured from the column's start edge. Right to left,
// its glyphs run from that (right) edge in this order (see `place_drop_cap`).
let (stops, indent) = tabs;
let mut x = indent - lsb;
for i in 0..dc.len() {
let tab = match dc.get(i) {
Some(g) if g.ch == '\t' => Some(tab_advance(stops, indent, x, dc.get(i + 1..).unwrap_or_default()).0),
_ => None,
};
if let (Some(w), Some(g)) = (tab, dc.get_mut(i)) {
g.adv = w;
}
x += dc.get(i).map_or(0.0, |g| g.adv);
}
let width = dc.iter().map(|g| g.adv).sum();
dc.append(&mut after);
Some(DropCap { glyphs: dc, end, lines, width, drop, lsb })
}
impl DropCap {
/// How far the lines beside it are indented: to the end of its advance.
fn indent(&self) -> f64 {
self.width - self.lsb
}
}
/// The drop cap's glyphs set from `at` (right to left: mirrored, its first character on the right).
fn place_drop_cap(dc: &DropCap, at: f64, rtl: bool) -> Vec<PlacedGlyph> {
let mut x = at;
let mut out = Vec::with_capacity(dc.glyphs.len());
for g in &dc.glyphs {
let pen = if rtl { at + at + dc.width - x - g.adv } else { x };
let mut p = place(g, pen);
p.y += dc.drop;
p.rtl = rtl;
out.push(p);
x += g.adv;
}
out
}
/// Put generated `label` text before the paragraph's glyphs; returns how many glyphs it added.
fn prepend_label(
db: &ScopedFonts<'_>,
glyphs: &mut Vec<Glyph>,
@ -3015,12 +2649,12 @@ fn prepend_label(
base: &designcraft_doc::CharProps,
env: shape::TypeEnv,
table: &mut StyleTable<'_>,
) -> usize {
) {
// Shape the label as a tiny standalone story so it uses the paragraph's base character style.
let mut tmp = Story::new(StoryId(0));
tmp.insert(0, label);
let styles = designcraft_doc::Styles::default();
let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[], (0, ""));
let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[]);
let mut pre: Vec<Glyph> = shaped
.glyphs
.into_iter()
@ -3030,10 +2664,8 @@ fn prepend_label(
g
})
.collect();
let n = pre.len();
pre.append(glyphs);
*glyphs = pre;
n
}
type LimitsKey = (usize, usize, usize, bool);
@ -3301,12 +2933,6 @@ pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Poi
/// A stacked warichu row reports that row's baseline and em, not the parent line's.
pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
let (fi, l) = caret_line(cs, pos)?;
// In the drop cap the caret is as tall as it.
if let Some(dc) = l.drop_cap
&& pos < dc.end
{
return Some((fi, caret_x(l, pos), dc.baseline, dc.baseline - dc.rect.y0, l.descent));
}
let x = caret_x(l, pos);
let glyphs = line_glyphs(l);
let metrics = caret_anchor(l, pos).filter(|g| on_stacked_row(&glyphs, g)).map(|g| {
@ -3354,8 +2980,6 @@ pub fn caret_x(l: &Line, pos: usize) -> f64 {
}
match l.glyphs.iter().rev().find(|g| g.len > 0) {
Some(g) if g.rtl => g.x,
// After the spaces that end the line.
Some(g) if !g.visible && g.x >= l.end_x - 1e-9 => g.x + g.adv,
_ => l.end_x,
}
}
@ -3389,57 +3013,19 @@ pub fn line_rtl(cs: &ComposedStory, pos: usize) -> bool {
caret_line(cs, pos).and_then(|(_, l)| l.glyphs.iter().find(|g| g.len > 0)).is_some_and(|g| g.rtl)
}
/// The line of frame `ft` closest to point `p` vertically (within its column).
fn hit_line(ft: &FrameText, p: Point) -> Option<&Line> {
ft.lines
.iter()
.filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1)
.min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y)))
.or_else(|| ft.lines.first())
}
/// Story byte of the character drawn under point `p` (frame inner space) in frame `fi`: the
/// glyph whose advance spans `p.x` on the closest line, else the one nearest it. For selecting
/// the word (or the spaces) under a double click; [`hit`] finds a caret position instead.
pub fn hit_char(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
let ft = cs.frames.get(fi)?;
let Some(l) = hit_line(ft, p) else { return Some(ft.range.start) };
let dist = |g: &&PlacedGlyph| {
let (a, b) = (g.x.min(g.x + g.adv), g.x.max(g.x + g.adv));
if p.x < a {
a - p.x
} else if p.x >= b {
p.x - b
} else {
0.0
}
};
// On a drop cap (it reaches down beside the lines after its own): its character under `p`.
for dl in &ft.lines {
let Some(dc) = dl.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue };
return dl.glyphs.iter().filter(|g| g.len > 0 && g.byte < dc.end).min_by(|a, b| dist(a).total_cmp(&dist(b))).map(|g| g.byte);
}
let under = l.glyphs.iter().filter(|g| g.len > 0).min_by(|a, b| dist(a).total_cmp(&dist(b)));
Some(under.map_or(l.range.start, |g| g.byte))
}
/// Story byte nearest to point `p` (frame inner space) in frame `fi`.
pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
let ft = cs.frames.get(fi)?;
if ft.lines.is_empty() {
return Some(ft.range.start);
}
// On a drop cap (it reaches down beside the lines after its own): the nearest caret position in it.
for l in &ft.lines {
let Some(dc) = l.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue };
return l
.glyphs
.iter()
.filter(|g| g.len > 0 && g.byte < dc.end)
.flat_map(|g| [g.byte, g.byte + g.len])
.min_by(|a, b| (caret_x(l, *a) - p.x).abs().total_cmp(&(caret_x(l, *b) - p.x).abs()));
}
let l = hit_line(ft, p)?;
// Closest line vertically (within its column).
let l = ft
.lines
.iter()
.filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1)
.min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y)))
.or_else(|| ft.lines.first())?;
if l.glyphs.iter().any(|g| g.rtl) {
// Bidi: the caret position drawn nearest the point.
return caret_stops(l).into_iter().min_by(|a, b| (a.1 - p.x).abs().total_cmp(&(b.1 - p.x).abs())).map(|s| s.0);
@ -3579,51 +3165,6 @@ 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(
@ -3674,47 +3215,3 @@ 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);
}
}

View File

@ -197,15 +197,10 @@ pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> {
/// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story).
pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> {
hit_note_with(cs, fi, p, crate::hit)
}
/// [`hit_note`] with the hit test for the note's text (see [`crate::hit_cell_with`]).
pub fn hit_note_with(cs: &ComposedStory, fi: usize, p: Point, hit: fn(&ComposedStory, usize, Point) -> Option<usize>) -> Option<(u64, usize)> {
let ft = cs.frames.get(fi)?;
let n = ft.notes.iter().find(|n| n.rect.contains(p))?;
let local = Point::new(p.x - n.origin.x, p.y - n.origin.y);
Some((n.id, hit(&n.text, 0, local).unwrap_or(0)))
Some((n.id, crate::hit(&n.text, 0, local).unwrap_or(0)))
}
/// Caret geometry for byte `pos` of a footnote's story, in frame inner space:

View File

@ -22,15 +22,12 @@ fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) ->
NestedUntil::ForcedLineBreak => c == designcraft_doc::FORCED_LINE_BREAK,
NestedUntil::EmSpace => c == '\u{2003}',
NestedUntil::EnSpace => c == '\u{2002}',
// Counted by position, not by character (see `overlays`).
NestedUntil::Dropcap => false,
NestedUntil::Chars(s) => s.contains(c),
}
}
/// Character-style ranges for the paragraph `text[range]`, in application order (later wins).
/// The paragraph's drop cap ends at `drop_end` (`range.start` without one).
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle], drop_end: usize) -> Vec<(Range<usize>, String)> {
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
let para = &text[range.clone()];
// The paragraph's own end mark isn't styled.
@ -40,22 +37,17 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
if pos >= end {
break;
}
let mut count = 0;
let mut stop = end;
if ns.until == NestedUntil::Dropcap {
// Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing.
stop = if ns.through { drop_end.max(pos).min(end) } else { pos };
} else {
let mut count = 0;
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
}
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
}
}
}
@ -92,21 +84,14 @@ mod tests {
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
let n = |style: &str, through: bool, count: u32, until: NestedUntil| NestedStyle { style: style.into(), through, count, until };
// Through the first ':' then up to the second word end.
let o =
overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[], 0);
let o = overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[]);
assert_eq!(&text[o[0].0.clone()], "Q:");
assert_eq!(&text[o[1].0.clone()], " what is", "up to the end of the second word");
// Digits by GREP.
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }], 0);
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }]);
let got: Vec<&str> = o.iter().map(|(r, _)| &text[r.clone()]).collect();
assert_eq!(got, ["42", "7"]);
// Through the drop cap (its first 2 bytes here); up to it is nothing; without one, nothing.
let d = |through: bool| n("Cap", through, 1, NestedUntil::Dropcap);
let o = overlays(&text, 0..text.len(), &[d(true), n("Next", true, 1, NestedUntil::Words)], &[], 2);
assert_eq!((&text[o[0].0.clone()], &text[o[1].0.clone()]), ("Q:", " what "));
assert_eq!(&text[overlays(&text, 0..text.len(), &[d(false), n("Next", true, 1, NestedUntil::Words)], &[], 2)[0].0.clone()], "Q: ");
assert!(overlays(&text, 0..text.len(), &[d(true)], &[], 0).is_empty());
// A bad pattern is ignored.
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }], 0).is_empty());
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }]).is_empty());
}
}

View File

@ -28,14 +28,9 @@ pub struct Glyph {
/// Baseline shift (positive = up), including super/subscript.
pub shift: f64,
pub ascent: f64,
/// The font's typographic ascender at the point size (vertical scale doesn't apply): the Ascent
/// first baseline offset.
pub typo_ascent: f64,
pub descent: f64,
/// The leading this character asks for (absolute, or auto = size × auto %).
pub leading: f64,
/// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline
/// offsets.
pub cap: f64,
pub xh: f64,
pub size: f64,
@ -69,9 +64,6 @@ pub struct Glyph {
/// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung
/// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]).
pub upright: bool,
/// Part of the bullet or number the paragraph's list adds: not the line's own text, so it
/// doesn't raise the Ascent first baseline.
pub list_label: bool,
}
impl Glyph {
@ -193,8 +185,7 @@ impl StyleTable<'_> {
}
}
/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without
/// one) and takes the character style `drop.1` ("" = none).
/// Resolve and shape one paragraph.
pub(crate) fn shape_para(
db: &ScopedFonts<'_>,
styles: &Styles,
@ -208,37 +199,13 @@ pub(crate) fn shape_para(
nested: &[designcraft_doc::NestedStyle],
grep: &[designcraft_doc::GrepStyle],
lines: &[(std::ops::Range<usize>, String)],
drop: (usize, &str),
) -> ShapedPara {
let _ = pi;
let mut glyphs = Vec::with_capacity(range.len());
// Nested line styles lie under nested and GREP styles, and the drop cap's style over them all
// (later overlays win).
// Nested line styles lie under nested and GREP styles (later overlays win).
let mut overlays: Vec<(std::ops::Range<usize>, String)> = lines.to_vec();
// The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML
// stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a
// missing entry. Either way the nested styles after it start after the drop cap.
let set = Some(drop.1).filter(|s| !s.is_empty());
let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE);
let with_cap: Vec<designcraft_doc::NestedStyle>;
let nested = match set {
Some(s) if nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => {
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().skip(1).cloned()).collect();
&with_cap[..]
}
Some(s) if cap_style.is_some() && !nested.is_empty() => {
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect();
&with_cap[..]
}
_ => nested,
};
if !nested.is_empty() || !grep.is_empty() {
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0));
}
if let Some(s) = cap_style
&& drop.0 > range.start
{
overlays.push((range.start..drop.0, s.to_string()));
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep));
}
// Each run, cut where nested / GREP styles start and end.
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
@ -306,7 +273,7 @@ pub(crate) fn shape_para(
}
let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c);
g.ch = HIDDEN;
(g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
(g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0);
glyphs.push(g);
}
continue;
@ -630,18 +597,10 @@ fn shape_run_raw(
Some(y) => {
g.adv = o.w;
g.ascent = g.ascent.max(o.h + y);
g.typo_ascent = g.typo_ascent.max(o.h + y);
g.cap = g.cap.max(o.h + y);
g.xh = g.xh.max(o.h + y);
g.descent = g.descent.max(-y);
}
// Pushes its line down by its height and spacing.
None => {
g.ascent += o.h + o.space;
g.typo_ascent += o.h + o.space;
g.cap += o.h + o.space;
g.xh += o.h + o.space;
}
None => g.ascent += o.h + o.space,
}
if auto || o.y_offset.is_none() {
g.leading = g.leading.max(g.ascent + g.descent);
@ -812,7 +771,6 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k,
descent,
leading,
cap,
@ -838,7 +796,6 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
shaping_rtl: false,
lang: designcraft_doc::language_tag(&p.language),
upright: false,
list_label: false,
}
}
@ -932,7 +889,6 @@ fn shape_segment(
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k,
descent,
leading,
cap,
@ -962,7 +918,6 @@ fn shape_segment(
shaping_rtl: sg.rtl,
lang,
upright: up,
list_label: false,
});
}
}

View File

@ -442,7 +442,6 @@ fn emit(
spacing: 1.0,
hj: 0,
keep_violation: false,
drop_cap: None,
});
ft.tables.push(TableFrag {
table: table.id,
@ -603,17 +602,6 @@ fn fragment_strokes(
/// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell).
pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, usize, usize)> {
hit_cell_with(cs, fi, p, crate::hit)
}
/// [`hit_cell`] with the hit test for the cell's text: [`crate::hit`] (caret position) or
/// [`crate::hit_char`] (character under the point).
pub fn hit_cell_with(
cs: &ComposedStory,
fi: usize,
p: Point,
hit: fn(&ComposedStory, usize, Point) -> Option<usize>,
) -> Option<(u64, usize, usize, usize)> {
let ft = cs.frames.get(fi)?;
for t in &ft.tables {
if !t.rect.contains(p) {
@ -622,7 +610,7 @@ pub fn hit_cell_with(
for c in &t.cells {
if c.rect.contains(p) {
let local = Point::new(p.x - c.origin.x, p.y - c.origin.y);
let b = hit(&c.text, 0, local).unwrap_or(0);
let b = crate::hit(&c.text, 0, local).unwrap_or(0);
return Some((t.table, c.row, c.col, b));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -431,8 +431,6 @@ pub enum NestedUntil {
ForcedLineBreak,
EmSpace,
EnSpace,
/// The paragraph's drop cap characters.
Dropcap,
/// Any of these characters.
Chars(String),
}
@ -469,18 +467,6 @@ impl Default for NestedStyle {
}
}
impl NestedStyle {
/// The drop cap's character style as a nested style: `style` through 1 drop cap.
pub fn drop_cap(style: &str) -> Self {
NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap }
}
/// Is this the form a drop cap's character style takes at the start of a nested style list?
pub fn is_drop_cap(&self) -> bool {
self.until == NestedUntil::Dropcap && self.through && self.count == 1
}
}
/// A character style applied to every match of a regular expression in the paragraph.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@ -544,16 +530,12 @@ pub struct TabStop {
pub align_on: String,
}
/// The colour of a paragraph rule that follows the paragraph's text colour.
pub const TEXT_COLOR: &str = "Text Color";
/// A paragraph rule (Rule Above / Rule Below).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Rule {
pub on: bool,
pub weight: f64,
/// A swatch, or [`TEXT_COLOR`].
pub color: String,
pub tint: f32,
/// Width: column (true) or text (false).
@ -565,7 +547,7 @@ pub struct Rule {
impl Default for Rule {
fn default() -> Self {
Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
}
}
@ -827,17 +809,8 @@ attr_set! {
last_line_indent: f64 = 0.0,
space_before: f64 = 0.0,
space_after: f64 = 0.0,
/// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never
/// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the
/// character style `drop_cap_style` ("" or `[None]`: the text's own formatting).
drop_cap_lines: u32 = 0,
drop_cap_chars: u32 = 0,
drop_cap_style: String = String::new(),
/// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side
/// bearing is taken off), and the lines beside it start where its advance ends.
drop_cap_align_left: bool = true,
/// Scale for Descenders (kept and round-tripped; doesn't change the layout).
drop_cap_scale_descenders: bool = false,
/// Nested styles, in order from the paragraph start.
nested_styles: Vec<NestedStyle> = Vec::new(),
/// GREP styles: a character style for every match of a pattern (applied after nested).
@ -882,8 +855,7 @@ attr_set! {
// Keeps
keep_with_next: u32 = 0,
keep_lines_together: bool = false,
/// All lines in paragraph (true) or at start/end of paragraph (false).
keep_all_lines: bool = false,
keep_all_lines: bool = true,
keep_first: u32 = 2,
keep_last: u32 = 2,
start_paragraph: StartParagraph = StartParagraph::Anywhere,

View File

@ -200,229 +200,87 @@ impl Document {
Ok((id, sid))
}
/// Thread `from` → `to`: `to` joins `from`'s story right after `from` (between `from` and the
/// frame that followed it, if any). If `to` held its own text, that text is appended to the
/// story (as new paragraphs) and its old story is removed; frames that followed `to` come
/// along, frames before it keep an empty story of their own. An empty (unassigned) `from` or
/// `to` becomes a text frame. `from`'s story keeps its id.
/// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text,
/// that text is appended to the story (as a new paragraph) and its old story is removed.
pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> {
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,
None => self.give_empty_story(from)?,
let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?;
// Convert unassigned/graphic-less frames into text frames.
let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() {
Content::Text(t) => Some(t.story),
Content::Unassigned => None,
_ => return Err(DocError::Invalid("can only thread to text or empty frames".into())),
};
if ts == Some(fs) {
return Err(DocError::Invalid("frames are already in the same story".into()));
}
let Some(ts) = ts else {
if let Some(ts) = ts {
let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?;
let other = Arc::unwrap_or_clone(other);
// Frames of the other story after `to` follow along; frames before it are left as empty new stories.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let before: Vec<ItemId> = other.frames[..pos_to].to_vec();
let after: Vec<ItemId> = other.frames[pos_to..].to_vec();
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
if !other.text.is_empty() {
st.text.push('\n');
st.text.push_str(&other.text);
st.paras.extend(other.paras.iter().cloned());
// Char runs: '\n' takes the last run's format, then the other story's runs.
if let Some(last) = st.chars.last_mut() {
last.len += 1;
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone())));
for n in &other.notes {
let id = st.next_note_id();
st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() }));
}
st.anchors.extend(other.anchors.iter().cloned());
st.xrefs.extend(other.xrefs.iter().cloned());
st.index_refs.extend(other.index_refs.iter().cloned());
st.objects.extend(other.objects.iter().cloned());
st.rev += 1;
}
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = other.vertical;
ns.direction = other.direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
} else {
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
st.frames.insert(at, to);
st.rev += 1;
let it = self.item_mut(to).ok_or(DocError::NoItem(to))?;
it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() });
return Ok(());
};
let mut other = Arc::unwrap_or_clone(self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?);
// Frames of the other story from `to` on follow along; frames before it are left as an empty new story.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
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);
(st.vertical, st.direction) = own;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
}
if let Some(st) = self.story_mut(fs) {
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;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = vertical;
ns.direction = direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
Ok(())
}
/// [`Document::thread`], except that an empty `from` alone in its story, threaded in front of
/// the first frame of `to`'s story, joins that story: the story with the text keeps its id
/// (and whatever refers to it).
pub fn thread_keeping_story(&mut self, from: ItemId, to: ItemId) -> Result<()> {
let fs = self.frame_story(from)?;
let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from]));
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);
st.rev += 1;
let it = self.item_mut(from).ok_or(DocError::NoItem(from))?;
match it.text_frame_mut() {
Some(t) => t.story = ts,
None => it.content = Content::Text(TextFrame { story: ts, options: TextFrameOptions::default() }),
}
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).
pub fn check_thread(&self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
for id in [from, to] {
let it = self.item(id).ok_or(DocError::NoItem(id))?;
if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine {
return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame")));
}
if it.locked || self.layer(it.layer).is_some_and(|l| l.locked) {
return Err(DocError::Invalid(format!("{id} is locked or on a locked layer")));
}
}
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
let same_place = match (fl.spread, tl.spread) {
(SpreadRef::Doc(_), SpreadRef::Doc(_)) => true,
(a, b) => a == b,
};
if !same_place {
return Err(DocError::Invalid("frames on a parent spread thread only to frames on the same parent".into()));
}
let story = |id: ItemId| self.item(id).and_then(|i| i.text_frame()).map(|t| t.story);
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")));
}
Ok(())
}
/// The story of a frame that can take part in a thread: `Some` for a text frame, `None` for
/// an empty (unassigned) frame.
fn frame_story(&self, id: ItemId) -> Result<Option<StoryId>> {
match &self.item(id).ok_or(DocError::NoItem(id))?.content {
Content::Text(t) => Ok(Some(t.story)),
Content::Unassigned => Ok(None),
_ => Err(DocError::Invalid("can only thread text frames or empty frames".into())),
}
}
/// Make an empty frame a text frame with a new empty story.
fn give_empty_story(&mut self, id: ItemId) -> Result<StoryId> {
let sid = StoryId(self.alloc());
let mut st = Story::new(sid);
st.frames.push(id);
let it = self.item_mut(id).ok_or(DocError::NoItem(id))?;
it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
self.stories.insert(sid, Arc::new(st));
Ok(sid)
}
/// Break the thread after `frame`: the following frames become a new empty story (the text
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
@ -451,30 +309,6 @@ impl Document {
Ok(())
}
/// Take `frame` out of its thread: the frames before and after it join up and keep the text,
/// `frame` stays on the page with a new empty story.
pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (vertical, direction) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
if st.frames.len() < 2 {
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
}
st.frames.retain(|f| *f != frame);
st.rev += 1;
(st.vertical, st.direction)
};
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = vec![frame];
ns.vertical = vertical;
ns.direction = direction;
self.stories.insert(nid, Arc::new(ns));
let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?;
t.story = nid;
Ok(())
}
/// Next / previous frame in a thread.
pub fn next_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
@ -582,11 +416,5 @@ 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;
}
// 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)
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, 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;

View File

@ -21,24 +21,3 @@ 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
}

View File

@ -281,10 +281,6 @@ pub enum FirstBaseline {
Leading,
XHeight,
Fixed,
/// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo
/// ascender when the typo ascender and descender span one em; else half an em above half the
/// cap height.
EmboxHeight,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -325,7 +321,7 @@ pub struct TextFrameOptions {
pub first_baseline_min: f64,
pub ignore_wrap: bool,
pub auto_size: AutoSize,
/// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing.
/// Reference point (0..9) that stays fixed when auto-sizing.
pub auto_size_ref: u8,
pub column_rule: bool,
pub column_rule_weight: f64,
@ -349,12 +345,8 @@ 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 (the start bracket).
/// Where the text starts, as a distance along the path.
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,
@ -386,7 +378,7 @@ impl Default for TextFrameOptions {
first_baseline_min: 0.0,
ignore_wrap: false,
auto_size: AutoSize::Off,
auto_size_ref: 4,
auto_size_ref: 1,
column_rule: false,
column_rule_weight: 1.0,
column_rule_color: designcraft_color::swatch::BLACK.into(),
@ -1160,29 +1152,13 @@ impl Item {
b.inflate(w, w)
}
/// The text frame's text area (inner space): inside the part of a visible stroke that lies
/// within the frame, then the inset, on each edge.
/// The frame's text area (inner space) after inset.
pub fn text_area(&self) -> Rect {
let r = self.inner_bounds();
let s = self.stroke_inside();
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]).map(|v| v + s);
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]);
Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0]))
}
/// How far a visible stroke reaches inside the path: half its weight centred, all of it inside,
/// none outside.
fn stroke_inside(&self) -> f64 {
let w = self.stroke.weight;
if self.stroke.is_none() || !w.is_finite() {
return 0.0;
}
match self.stroke.align {
StrokeAlign::Center => w / 2.0,
StrokeAlign::Inside => w,
StrokeAlign::Outside => 0.0,
}
}
/// Spread-space centre.
pub fn center(&self) -> Point {
self.bounds().center()
@ -1227,32 +1203,6 @@ mod tests {
assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0));
}
#[test]
fn text_area_starts_inside_the_stroke() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } });
let unstroked = Rect::new(6.0, 5.0, 92.0, 43.0);
let stroked = |align: StrokeAlign, swatch: &str| {
let mut it = it.clone();
it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() };
it.text_area()
};
let black = designcraft_color::swatch::BLACK;
// The part of the stroke inside the frame, on every edge, then the inset.
assert_eq!(stroked(StrokeAlign::Center, black), Rect::new(8.0, 7.0, 90.0, 41.0));
assert_eq!(stroked(StrokeAlign::Inside, black), Rect::new(10.0, 9.0, 88.0, 39.0));
assert_eq!(stroked(StrokeAlign::Outside, black), unstroked);
// A stroke without a colour doesn't count.
assert_eq!(stroked(StrokeAlign::Inside, designcraft_color::swatch::NONE), unstroked);
// Nor does a weight that isn't a number.
it.stroke = Stroke { weight: f64::NAN, ..Stroke::default() };
assert_eq!(it.text_area(), unstroked);
// A stroke wider than the frame leaves no room for text.
it.stroke = Stroke { weight: 200.0, align: StrokeAlign::Inside, ..Stroke::default() };
let a = it.text_area();
assert!(a.width() == 0.0 && a.height() == 0.0, "{a:?}");
}
#[test]
fn group_bounds_union_children() {
let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));

View File

@ -143,7 +143,7 @@ impl Default for DocumentGrid {
}
/// A named numbered list.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NumberedList {
pub name: String,
@ -151,6 +151,12 @@ pub struct NumberedList {
pub continue_across_stories: bool,
}
impl Default for NumberedList {
fn default() -> Self {
NumberedList { name: String::new(), continue_across_stories: true }
}
}
/// A conditional-text condition: its indicator colour (screen only) and whether text with it shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -446,16 +452,6 @@ 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
}

View File

@ -178,7 +178,7 @@ impl Story {
}
pub(crate) fn next_note_id(&self) -> u64 {
crate::ids::next_local_id(self.notes.iter().map(|n| n.id))
self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1
}
/// Insert empty footnotes for `n` new reference characters before which `k` references sit.

View File

@ -246,8 +246,7 @@ impl Page {
/// Split `area` into `n` equal columns separated by `gutter`.
pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> {
// The public allocator also receives native/imported options, outside command validation.
let n = n.clamp(1, 216) as usize;
let n = n.max(1) 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()
}
@ -404,18 +403,3 @@ 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.)]);
}
}

View File

@ -126,66 +126,6 @@ impl Story {
self.rev += 1;
end
}
/// Append the whole of `other` as new paragraphs after this story's text (threading two
/// stories merges them this way). Its formatting, notes, endnotes, editorial notes, markers,
/// anchored objects and tables come along; footnotes, endnotes, editorial notes and tables get
/// ids that are free in this story. An empty story simply takes `other`'s content. Frames stay.
pub fn append_story(&mut self, mut other: Story) {
if other.text.is_empty() {
return;
}
if self.text.is_empty() {
let (id, frames, rev, link) = (self.id, std::mem::take(&mut self.frames), self.rev, self.link.take());
*self = Story { id, frames, rev: rev.saturating_add(1), link, ..other };
return;
}
if !other.tables.is_empty() {
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;
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());
}
}
self.text.push('\n');
self.text.push_str(&other.text);
// The paragraph end takes the last run's format.
if let Some(last) = self.chars.last_mut() {
last.len += 1;
}
self.chars.extend(other.chars.into_iter().filter(|r| r.len > 0));
self.paras.extend(other.paras);
for n in other.notes {
let id = self.next_note_id();
self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.endnotes {
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 = 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);
self.xrefs.extend(other.xrefs);
self.index_refs.extend(other.index_refs);
self.objects.extend(other.objects);
self.rev = self.rev.saturating_add(1);
}
}
#[cfg(test)]
@ -219,52 +159,4 @@ mod tests {
assert_eq!(part.notes.len(), 1);
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());
a.tables.insert(u64::MAX, Arc::new(crate::table::Table::new(u64::MAX, 1, 1, 0, 0, 100.0)));
let note = |id| Arc::new(crate::notes::Footnote { id, text: Story::with_text(StoryId(0), "n", ParaFormat::default()) });
a.endnotes.push(note(u64::MAX));
a.notes.push(note(u64::MAX));
a.rev = u64::MAX;
let mut b = Story::with_text(StoryId(2), "Beta", ParaFormat::default());
b.tables.insert(1, Arc::new(crate::table::Table::new(1, 1, 1, 0, 0, 100.0)));
b.endnotes.push(note(1));
b.notes.push(note(1));
a.append_story(b);
assert_eq!(a.text, "Alpha\nBeta");
assert_eq!(a.endnotes.len(), 2);
assert_eq!(a.notes.len(), 2);
}
}

View File

@ -692,8 +692,8 @@ impl Styles {
self.object.iter().find(|s| s.name == name)
}
/// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first.
pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
/// The paragraph style chain from root to `name` (cycle-safe).
fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
let mut chain = Vec::new();
let mut cur = self.para(name);
while let Some(s) = cur {

View File

@ -695,79 +695,6 @@ 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.
@ -792,11 +719,10 @@ impl Table {
return;
}
if horizontal {
let row = self.rows[or].clone();
let h = self.rows[or].height;
self.insert_rows(or + 1, 1);
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 };
self.rows[or].height = h / 2.0;
self.rows[or + 1].height = h / 2.0;
// 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();
@ -1329,89 +1255,4 @@ 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);
}
}

View File

@ -42,126 +42,6 @@ fn thread_to_empty_frame_converts_it() {
assert!(d.thread(a, a).is_err());
}
#[test]
fn threading_merges_tables_notes_and_endnotes() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap();
for (sid, note) in [(sa, "Note A"), (sb, "Note B")] {
let st = d.story_mut(sid).unwrap();
st.insert_note(3, note, ParaFormat::default());
// Both stories hold a table with id 1.
let n = st.len();
st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0));
}
let st = d.story_mut(sb).unwrap();
st.insert_endnote(2, "End B", ParaFormat::default());
st.insert(1, &crate::NOTE_MARK.to_string());
d.thread(a, b).unwrap();
d.check().unwrap();
let st = d.story(sa).unwrap();
assert!(d.story(sb).is_none());
assert_eq!(st.tables.len(), 2);
assert_eq!(st.notes.len(), 2);
assert_eq!(st.notes[1].text.text, "Note B");
assert_eq!(st.endnotes.len(), 1);
assert_eq!(st.endnotes[0].text.text, "End B");
assert_eq!(st.editorial.len(), 1);
}
#[test]
fn empty_frame_threaded_before_a_story_joins_it_at_the_front() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread_keeping_story(a, b).unwrap();
d.check().unwrap();
assert!(d.story(sa).is_none());
let st = d.story(sb).unwrap();
assert_eq!(st.frames, vec![a, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() {
let mut d = doc();
let lid = d.default_layer();
let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, a2).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(a2, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![a, a2, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn threading_from_an_empty_frame_makes_it_a_text_frame() {
let mut d = doc();
let lid = d.default_layer();
let id = ItemId(d.alloc());
let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0)));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(id, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![id, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, b).unwrap();
d.thread(b, c).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
assert_eq!(d.story(sa).unwrap().frames, vec![a, c]);
assert_eq!(d.story(sa).unwrap().text, "words");
let own = d.item(b).unwrap().text_frame().unwrap().story;
assert_ne!(own, sa);
assert!(d.story(own).unwrap().text.is_empty());
// A frame alone in its story has no thread to leave.
assert!(d.remove_from_thread(b).is_err());
}
#[test]
fn a_frame_removed_from_a_vertical_thread_stays_vertical() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "縦書き", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.story_mut(sa).unwrap().vertical = true;
d.thread(a, b).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
assert!(d.frame_vertical(d.item(a).unwrap()));
assert!(d.frame_vertical(d.item(b).unwrap()), "the removed frame keeps the story's direction");
}
#[test]
fn a_frame_removed_from_a_right_to_left_thread_keeps_its_column_order() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "نص", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.story_mut(sa).unwrap().direction = crate::TextDirection::RightToLeft;
d.thread(a, b).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
let own = d.item(b).unwrap().text_frame().unwrap().story;
assert_eq!(d.story(own).unwrap().direction, crate::TextDirection::RightToLeft);
}
#[test]
fn frames_keep_their_story_direction_through_threading() {
let mut d = doc();

View File

@ -30,7 +30,6 @@ log = { workspace = true }
regex = { workspace = true }
quick-xml = { workspace = true }
caseless = { workspace = true }
unicode-segmentation = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = { workspace = true }

View File

@ -1,359 +0,0 @@
//! 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);
}
}

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()))?;
crate::atomic::write_file(&b.path, &bytes)
std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path)))
}
#[cfg(target_arch = "wasm32")]
@ -47,176 +47,20 @@ 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).map_err(|e| EngineError::Other(format!("{path}: {e}")))
super::file::from_bytes(&bytes)
}
#[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(())
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"))]
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(
@ -264,16 +108,15 @@ 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 mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let b = s.book.as_mut().ok_or_else(no_book)?;
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)?;
s.book = Some(b);
save(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 mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let b = s.book.as_mut().ok_or_else(no_book)?;
if i >= b.documents.len() {
return Err(bad("book.remove", format!("no document {i}")));
}
@ -281,19 +124,17 @@ pub fn specs() -> Vec<CommandSpec> {
if b.style_source >= b.documents.len() {
b.style_source = 0;
}
save(&b)?;
s.book = Some(b);
save(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 mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let b = s.book.as_mut().ok_or_else(no_book)?;
if i >= b.documents.len() {
return Err(bad("book.styleSource", format!("no document {i}")));
}
b.style_source = i;
save(&b)?;
s.book = Some(b);
save(b)?;
ok()
}),
cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| {
@ -308,10 +149,48 @@ 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, _| {
paginate(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}))
}),
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, _| {
sync_styles(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}))
}),
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)?;
@ -331,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
@ -356,7 +235,6 @@ 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();

View File

@ -1,52 +0,0 @@
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"));
}

View File

@ -367,16 +367,10 @@ fn sniff(name: &str, text: &str) -> Delimiter {
if ext == "csv" {
return Delimiter::Comma;
}
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) {
let header = first_physical_line(text);
if header.contains('\t') {
Delimiter::Tab
} else if eligible(&semicolon) && !eligible(&comma) {
} else if header.contains(';') && !header.contains(',') {
Delimiter::Semicolon
} else {
Delimiter::Comma
@ -387,66 +381,8 @@ pub fn extension(name: &str) -> String {
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
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 }
fn first_physical_line(text: &str) -> &str {
text.split('\n').next().unwrap_or("").trim_end_matches('\r')
}
/// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr.
@ -516,7 +452,6 @@ 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;
@ -566,25 +501,21 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
};
while let Some(c) = chars.next() {
if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) {
if c == '\n' {
line = line.saturating_add(1);
}
if quoted {
match c {
'"' if chars.peek() == Some(&'"') => {
if !discarding {
field.push('"');
}
field.push('"');
chars.next();
}
'"' => quoted = false,
_ if !discarding => field.push(c),
_ => {}
_ => field.push(c),
}
continue;
}
if c == delim {
field_start = true;
if discarding {
field.clear();
continue;
@ -600,25 +531,14 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
continue;
}
match c {
'"' if field_start => {
quoted = true;
field_start = false;
}
'\r' | '\n' => {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
line = line.saturating_add(1);
}
'"' if field.is_empty() && !discarding => quoted = true,
'\r' => {}
'\n' => {
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 {

View File

@ -85,282 +85,6 @@ 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");

View File

@ -20,6 +20,13 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) }
}
fn has_clip_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.text.is_some()) {
return Ok(());
}
has_clip(s)
}
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) {
return Ok(());
@ -81,73 +88,7 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
});
}
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
}
/// Text from the system clipboard as story text: Unix line ends, and none of the private-use
/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and
/// variables, since a stray one would be read as a marker without its payload.
fn outside_text(text: &str) -> Result<String> {
let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) }
}
/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column
/// of the page's margin area and as tall as the text (at most the margin area's height), centred
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With
/// `slice` (the text copied here), the frame's story is that formatted copy.
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 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)));
let lid = s.doc()?.active_layer;
let cache = s.cache.clone();
let text = if slice.is_some() { String::new() } else { outside_text(text)? };
s.edit(|d, sel| {
let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?;
let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first());
let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?;
let margins = page.margin_rect();
let column = page.column_rects().first().copied().unwrap_or(margins);
let w = column.width().max(12.0);
let max_h = margins.height().max(12.0);
let c = at.unwrap_or(Point::new(column.center().x, margins.center().y));
let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
let para = match slice.and_then(|sl| sl.paras.first()) {
Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() },
None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() },
};
let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?;
if let Some(sl) = slice {
d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl);
}
// Shrink to the composed text unless it already fills the frame.
let composed = cache.get(d, sid, None);
if !composed.is_overset()
&& let Some(ft) = composed.frame(id)
{
let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]);
let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h);
if h.is_finite()
&& h < max_h
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(h));
// Keep the full height if the last line no longer fits.
if cache.get(d, sid, None).is_overset()
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(max_h));
}
}
}
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0, "story": sid.0}))
})
s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true}))
}
fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story {
@ -169,8 +110,6 @@ 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, _| {
@ -180,7 +119,6 @@ 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| {
@ -289,18 +227,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Edit"],
Some("Cmd+V"),
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), spread?, at?: [x, y] (spread coords: the new text frame's centre)}",
// The system clipboard may hold text from another app, so Paste is always available.
has_doc,
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}",
has_clip_or_text,
|s, p| {
let text = super::str_param(p, "text").filter(|t| !t.is_empty());
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
return paste_text(s, super::str_param(p, "text").map(str::to_string), true);
}
let Some(clip) = s.clipboard.clone() else {
return Err(super::bad("edit.paste", "clipboard is empty"));
@ -385,87 +316,6 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
}
#[test]
fn paste_text_with_no_insertion_point_makes_a_text_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
// Text from another app, nothing copied here: a new, selected text frame.
let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let d = s.doc().unwrap();
assert_eq!(d.selection.items, vec![id]);
assert!(d.selection.text.is_none(), "selected as an object");
let it = d.doc.item(id).unwrap();
assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line");
let page = &d.doc.spreads[0].pages[0];
let (b, m) = (it.bounds(), page.margin_rect());
assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}");
assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}");
assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}");
assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(items(&s), 0, "one undo removes it");
// Centred on `at`; hostile numbers are clamped.
let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap();
let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds();
assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}");
assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err());
s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap();
// Copied objects paste when no text is given.
s.execute("edit.deselectAll", &json!({})).unwrap();
let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let n = items(&s);
let r = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
assert_eq!(items(&s), n + 1);
assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// Private-use characters from another app would read as footnote, table or variable markers.
let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}";
let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap();
let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}");
s.doc().unwrap().doc.check().unwrap();
let b = frame(&mut s, "Target");
s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap();
s.execute("edit.paste", &json!({"text": dirty})).unwrap();
s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}");
assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = frame(&mut s, "Bold words\nSecond");
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap();
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap();
let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": copied})).unwrap();
let d = s.doc().unwrap();
let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.text, "Bold words\nSecond");
assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold"));
assert_eq!(st.char_format_at(7).over.font_style, None);
assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]);
d.doc.check().unwrap();
}
#[test]
fn formatted_copy_paste_and_plain_paste() {
let mut s = Session::new();

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 = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
let (text, author) = (str_param(p, "text").map(str::to_string), 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}")))?;

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};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
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 = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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 = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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.

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"))]
crate::atomic::write_file(path, &r.bytes)?;
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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"))]
crate::atomic::write_file(path, text.as_bytes())?;
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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"))]
crate::atomic::write_file(path, text.as_bytes())?;
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
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<()> {
crate::atomic::write_file(path, bytes)
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {

View File

@ -25,11 +25,9 @@ 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 mut copy = (*s.doc()?.doc).clone();
super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
let bytes = to_bytes(&copy);
let bytes = to_bytes(&s.doc()?.doc);
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(&path, &bytes)?;
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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),
@ -39,7 +37,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_tracked(s, &dir)?}))
Ok(json!({"saved": crate::recovery::save(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)?;
@ -239,26 +237,24 @@ 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)"))?;
// 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"))]
crate::atomic::write_file(&path, &bytes)?;
if paths_changed {
st.doc = saved;
st.revision = st.revision.saturating_add(1);
{
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);
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
st.path = Some(path.clone());
st.saved_revision = st.revision;
st.saved_doc = st.doc.clone();
#[cfg(not(target_arch = "wasm32"))]
{
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());
if let Some(dir) = &s.recovery_dir {
let uid = s.doc()?.uid;
crate::recovery::discard(dir, uid);
}
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@ -335,7 +331,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, "file.place")?;
let spread = super::spread_param(p, "spread");
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);
@ -458,7 +454,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, "place.drop")?;
let sr = super::spread_param(p, "spread");
let lid = s.doc()?.active_layer;
let frame = super::id_param(p, "frame");
let rect = super::rect_param(p, "rect");
@ -522,7 +518,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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()})),
@ -550,7 +546,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, ID)?;
let to = super::spread_param(p, "spread");
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),
@ -586,7 +582,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, "snippet.place")?;
let sr = super::spread_param(p, "spread");
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),
@ -647,14 +643,15 @@ 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 recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
let st = s.doc_mut()?;
let mut fresh = DocState::new(d, Some(path.clone()));
fresh.fonts = fonts;
fresh.uid = uid;
fresh.revision = s.doc()?.revision + 1;
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
*s.doc_mut()? = fresh;
fresh.revision = st.revision + 1;
*st = fresh;
if let Some(dir) = &s.recovery_dir {
crate::recovery::discard(dir, uid);
}
Ok(json!({"path": path}))
}
#[cfg(target_arch = "wasm32")]
@ -738,37 +735,6 @@ 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};
@ -989,24 +955,4 @@ mod document_fonts_tests {
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bold_and_italic_link_the_document_fonts_styles() {
const FAMILY: &str = "DocFont Linked";
let dir = temp_dir("linked");
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
let path = dir.join("Linked.designcraft");
write_document(&path, FAMILY);
let mut s = Session::new();
open(&mut s, &path);
let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
// The family is the document's alone: its Regular is the style without bold or italic.
let r = s.execute("type.bold", &json!({"on": false})).unwrap();
assert_eq!(r["fontStyle"], "Regular", "{r}");
let r = s.execute("type.italic", &json!({"on": false})).unwrap();
assert_eq!(r["fontStyle"], "Regular", "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}

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 = super::spread_param(p, "guide.deleteAll")?;
let r = guide_spread(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(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let sp = d.spread(guide_spread(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,6 +71,23 @@ 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")))?;
@ -96,7 +113,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 = super::spread_param(p, "guide.add")?;
let r = guide_spread(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")? {
@ -122,7 +139,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((super::spread_param(p, cmd)?, pi, i))
Ok((guide_spread(p, cmd)?, pi, i))
}
/// Commands must use the same guide/layer protection as pointer editing.
@ -220,7 +237,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 = super::spread_param(p, "layout.createGuides")?;
let r = guide_spread(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"))?;

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"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),

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()))?;
crate::atomic::write_file(p, &bytes)?;
std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?;
}
Ok(())
}
@ -86,12 +86,10 @@ 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 mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let index = lib.items.len();
let lib = s.library.as_mut().ok_or_else(no_library)?;
lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) });
lib.save()?;
s.library = Some(lib);
Ok(json!({"index": index}))
Ok(json!({"index": lib.items.len() - 1}))
}),
cmd!(
"library.place",
@ -109,14 +107,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| {
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let lib = s.library.as_mut().ok_or_else(no_library)?;
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| {

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, "story.placeAndLink")?;
let sr = spread_param(p, "spread");
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
if d.story(parent).is_none() {

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, "guide.liquid")?;
let sr = super::spread_param(p, "spread");
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);

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, "math.insert")?;
let spread = super::spread_param(p, "spread");
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 {

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, "file.place")?;
let spread = super::spread_param(p, "spread");
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();

View File

@ -50,7 +50,6 @@ mod strokes;
mod style;
pub mod table;
pub mod text;
mod thread;
mod toc;
mod transitions;
mod variables;
@ -185,7 +184,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(package::specs());
v.extend(print::specs());
v.extend(text::specs());
v.extend(thread::specs());
v.extend(style::specs());
v.extend(table::specs());
v.extend(layout::specs());
@ -256,23 +254,12 @@ 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)?))
}
/// 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`")),
/// `{"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),
}
}

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};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Footnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
has_doc,
options
),
@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> {
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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 = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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}")))?;

View File

@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Frame",
[],
None,
"{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)}",
"{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)}",
has_doc,
frame_create
),
@ -185,24 +185,6 @@ pub fn specs() -> Vec<CommandSpec> {
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",
@ -532,7 +514,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
text_frame_options
),
@ -613,34 +595,8 @@ pub fn specs() -> Vec<CommandSpec> {
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), 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.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.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
cmd!(
"object.align",
@ -872,7 +828,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, "frame.create")?;
let sr = spread_param(p, "spread");
let lid = s.doc()?.active_layer;
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
@ -935,7 +891,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, "line.create")?;
let sr = spread_param(p, "spread");
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
@ -1362,43 +1318,6 @@ 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 {
@ -1411,6 +1330,7 @@ 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);
@ -1484,60 +1404,6 @@ 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() {
@ -1726,7 +1592,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, ID)?;
let sr = spread_param(p, "spread");
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 =
@ -2021,10 +1887,6 @@ 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;
}
@ -2057,7 +1919,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, "path.create")?;
let sr = spread_param(p, "spread");
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
@ -2073,20 +1935,11 @@ 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 = 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"));
}
let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?;
sp.anchors.push(a.transform(inv));
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
@ -2095,103 +1948,12 @@ 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))?;
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;
}
for sp in &mut it.path.subpaths {
sp.closed = true;
}
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
@ -2359,190 +2121,10 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
})
}
#[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);
@ -3022,18 +2604,6 @@ 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");
}
}
@ -3243,87 +2813,3 @@ mod swatch_name_tests {
}
}
}
#[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());
}
}

View File

@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{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",
"{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from 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::text_param(p, "text");
let text = super::str_param(p, "text").unwrap_or("").to_string();
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,7 +86,6 @@ 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 {
@ -96,26 +95,8 @@ 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).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("start").and_then(Value::as_f64) {
pt.start = v.max(0.0);
}
if let Some(v) = p.get("flip").and_then(Value::as_bool) {
pt.flip = v;
@ -123,7 +104,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = align {
pt.align = a;
}
Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align}))
Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align}))
})
}
@ -179,93 +160,4 @@ 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:?}");
}
}
}

View File

@ -124,25 +124,19 @@ fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
}
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result<usize> {
let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) {
return Err(bad(cmd, "autoflow works only from a frame on a document page"));
}
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
let mut added = 0;
loop {
let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { return Ok(added) };
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
}
let len = d.story(sid).map_or(0, |s| s.len());
// Frames still needed at the rate the full ones are filled (at least one); with no full
// frame to go by yet, one more page.
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added);
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
d.insert_pages(Some(page), want, parent)?;
@ -150,7 +144,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let mut prev = last;
for _ in 0..want {
page += 1;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?;
let r = d.spreads[si].pages[pi].margin_rect();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
d.thread(prev, fid)?;
@ -236,7 +230,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
*sel = designcraft_doc::Selection::items(vec![fid]);
sid
};
let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 };
let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 };
let overset = fit(d, sid).0.is_some();
Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings}))
})

View File

@ -5,7 +5,6 @@
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};
@ -30,12 +29,10 @@ 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, parent: Affine) -> Option<usize> {
fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> {
let SpreadRef::Doc(si) = sr else { return None };
let sp = d.spreads.get(si)?;
// 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))
Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0))
}
/// Characters a missing-glyph report names before "and N more".
@ -88,7 +85,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, Affine::IDENTITY));
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it));
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 });
}
@ -164,52 +161,48 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
page: None,
});
}
// Carry the ancestor transform in the same order as rendering, retaining pre-order.
// Items.
for (si, sp) in d.spreads.iter().enumerate() {
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,
});
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,
});
}
}
}
},
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.

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, ID)?;
let sr = spread_param(p, "spread");
let st = s.doc()?;
let lid = st.active_layer;
let sel = st.selection.items.clone();

View File

@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Character Style…",
[],
None,
"{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)",
"{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)",
has_doc,
create_char
),
@ -180,19 +180,10 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Color",
[],
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)",
has_text_or_selection,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection",
super::has_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",
@ -631,26 +622,8 @@ 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 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.
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.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, _| {
@ -871,11 +844,7 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = designcraft_color::ase::write(&chosen);
match str_param(p, "path") {
Some(path) => {
#[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}")))?;
std::fs::write(path, &bytes).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)})),
@ -982,146 +951,41 @@ 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 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, _| {
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() }
} else {
CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() }
};
super::text::apply_char_attrs(d, &texts, &a, &[]);
let ids = super::targets(s, p)?;
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;
}
}
}
Ok(json!({"swatch": name}))
})?;
if caret && let Some(name) = r.get("swatch").cloned() {
let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) };
super::text::format_chars(s, &attrs)?;
}
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> {
@ -1135,7 +999,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let rename = str_param(p, "name").map(str::to_string);
let result = s.edit(|d, _| {
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"));
@ -1162,84 +1026,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
rename_in(std::sync::Arc::make_mut(top), &name, &new);
}
}
for st in d.stories.values_mut() {
let mut copy = (**st).clone();
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, 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 {
*s = to.to_string();
}
hit
}
let mut n = 0;
let mut f = |s: &mut String| n += usize::from(fix(s, from, to));
for r in &mut st.chars {
let o = &mut r.format.over;
for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() {
f(c);
}
}
for pf in &mut st.paras {
let a = &mut pf.para;
for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() {
f(c);
}
for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() {
f(&mut r.color);
}
}
let mut nested = false;
for t in st.tables.values_mut() {
let t = std::sync::Arc::make_mut(t);
let o = &mut t.options;
f(&mut o.border.color);
for b in o.borders.iter_mut().flatten() {
f(&mut b.color);
}
for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() {
f(&mut a.first_color);
f(&mut a.next_color);
}
for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() {
f(&mut k.first_stroke.color);
f(&mut k.next_stroke.color);
}
for c in &mut t.cells {
f(&mut c.fill);
for k in &mut c.strokes {
f(&mut k.color);
}
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, depth + 1)?;
}
let changed = n > 0 || nested;
if changed {
st.rev = st.rev.wrapping_add(1);
}
Ok(changed)
})
}
/// Point fills and strokes of an item (and its children) at a renamed swatch.
@ -1553,40 +1341,6 @@ mod color_tests {
s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand");
}
#[test]
fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() {
use std::sync::Arc;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap();
s.execute("footnote.insert", &json!({"text": "note"})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap();
let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string();
// The same unnamed colour on a cell, the cell's text and the footnote's text.
s.edit(|d, _| {
let st = d.story_mut(sid).unwrap();
let t = Arc::make_mut(st.tables.values_mut().next().unwrap());
t.cells[0].fill = old.clone();
t.cells[0].text.chars[0].format.over.fill = Some(old.clone());
Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone());
Ok(())
})
.unwrap();
s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap();
let d = &s.doc().unwrap().doc;
let st = d.story(sid).unwrap();
assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand"));
let cell = &st.tables.values().next().unwrap().cells[0];
assert_eq!(cell.fill, "Sand");
assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand"));
assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand"));
d.check().unwrap();
}
}
#[cfg(test)]
@ -1735,116 +1489,3 @@ 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;

View File

@ -178,7 +178,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Split Cell Horizontally",
["Table"],
None,
"{} — each target cell becomes two, one above the other",
"{} — the 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,
"{} — each target cell becomes two, side by side",
"{} — the 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_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?;
t.split_cell(g.range.r0, g.range.c0, horizontal);
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(|_| super::text_param(p, "text"));
let text = str_param(p, "text").map(str::to_string);
let stroke = p.get("stroke").cloned();
edit_table(s, &g, "table.setCell", |t| {
let owners = t.owners();

View File

@ -22,26 +22,14 @@ pub fn specs() -> Vec<CommandSpec> {
release
),
cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| {
select_at(s, p, "word")
}),
cmd!(
noundo "text.selectAt",
"Select Text Unit",
[],
None,
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story",
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.selectWord", "Select Word", [], None, "{frame, point}", has_doc, |s, p| {
place(s, p, false)?;
let st = s.doc_mut()?;
let Some(t) = st.selection.text else { return ok() };
let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default();
let (a, b) = word_bounds(text, t.focus);
st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
ok()
}),
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));
@ -51,7 +39,6 @@ pub fn specs() -> Vec<CommandSpec> {
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),
@ -89,7 +76,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 = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, sel| {
d.set_story_text(sid, &text)?;
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
@ -110,7 +97,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 = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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())));
@ -148,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
@ -165,33 +152,8 @@ pub fn specs() -> Vec<CommandSpec> {
s,
&json!({"align": "leftJustified"})
)),
cmd!(
"type.bold",
"Bold",
[],
Some("Cmd+Shift+B"),
"{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out",
has_text_or_frames,
|s, p| link_style(s, p, true)
),
cmd!(
"type.italic",
"Italic",
[],
Some("Cmd+Shift+I"),
"{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out",
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.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))),
cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))),
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,
@ -291,7 +253,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, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
]
}
@ -309,17 +271,6 @@ pub(crate) fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell).
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> {
hit_byte_with(s, frame, pt, compose::hit)
}
/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`]
/// (the character under the point).
fn hit_byte_with(
s: &Session,
frame: ItemId,
pt: Point,
hit: fn(&compose::ComposedStory, usize, Point) -> Option<usize>,
) -> Option<(StoryId, usize, Option<CellAddr>)> {
let st = s.active()?;
let loc = st.doc.find(frame)?;
let it = st.doc.item_at(&loc)?;
@ -328,94 +279,16 @@ 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) {
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
return Some((sid, b, Some(CellAddr { table, row, col })));
}
if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) {
if let Some((id, b)) = compose::hit_note(&cs, fi, inner) {
return Some((sid, b, Some(CellAddr::footnote(id))));
}
let b = hit(&cs, fi, inner).unwrap_or(0);
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
Some((sid, b, None))
}
/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks).
fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
const ID: &str = "text.selectAt";
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?;
let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?;
let st = s.doc()?;
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let text = story.text.as_str();
let b = floor_char_boundary(text, b);
let (a, e) = match unit {
"word" => word_or_spaces_at(text, b),
"line" => {
let cs = s.cache.get(&st.doc, sid, None);
line_at(&text_composition(cs, cell), b).unwrap_or((b, b))
}
// The paragraph with its return (the last paragraph has none).
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))),
"story" => (0, text.len()),
other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
};
s.text_drag = false;
s.typing_format = None;
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
st.revision += 1;
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
}
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character
/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split:
/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base.
fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) {
use unicode_segmentation::UnicodeSegmentation;
let i = floor_char_boundary(s, i.min(s.len()));
// Clusters never span a paragraph return, so the paragraph is enough.
let ps = s[..i].rfind('\n').map_or(0, |k| k + 1);
let pe = s[i..].find('\n').map_or(s.len(), |k| i + k);
let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect();
let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) };
let kind = |c: &str| match c.chars().next() {
Some(c) if is_word(c) => 1,
Some(c) if c.is_whitespace() => 2,
_ => 0,
};
let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len()));
let kd = g.get(k).map_or(0, |(_, c)| kind(c));
if kd == 0 {
return span(k, k);
}
let mut a = k;
while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) {
a -= 1;
}
let mut e = k;
while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) {
e += 1;
}
span(a, e)
}
/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's.
fn text_composition(cs: std::sync::Arc<compose::ComposedStory>, cell: Option<CellAddr>) -> std::sync::Arc<compose::ComposedStory> {
match cell {
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
}
}
/// The composed line holding the character at byte `pos`.
fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
let lines = || cs.frames.iter().flat_map(|f| f.lines.iter());
lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos))
}
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
@ -434,7 +307,6 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
} else if s.text_drag {
return Ok(json!({"story": sid.0, "dragging": true}));
}
s.typing_format = None;
let st = s.doc_mut()?;
// Dragging from one cell into another selects cells.
if extend
@ -502,8 +374,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
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();
let result = s.edit(|d, sel| {
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();
@ -563,9 +434,6 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
// Track Changes: the replaced text is marked deleted, the typing inserted after it.
let at = super::changes::mark_deleted(st, r.clone());
let mut fmt = st.char_format_at(at).clone();
if let Some(f) = &typing {
apply_typing_format(&mut fmt, f);
}
fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted);
st.insert_with(at, &text, fmt);
let pos = at + text.len() - trail;
@ -573,19 +441,10 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"pos": pos}));
}
st.replace(r.clone(), &text);
if let Some(f) = &typing
&& !text.is_empty()
{
st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f));
}
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 {
@ -762,62 +621,12 @@ 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);
fn word_bounds(s: &str, i: usize) -> (usize, usize) {
let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i);
let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len());
(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"))?;
@ -856,8 +665,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = story.text.clone();
let cs = s.cache.get(&st.doc, t.story, None);
// In a cell, lines come from the cell's own composition.
let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell);
let cs = match t.cell {
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
};
let pos = t.focus.min(text.len());
// Vertical lines run top to bottom and follow each other right to left: Down and Up move along
// the line (Right and Left of horizontal text), Left and Right to the next and previous line.
@ -898,7 +712,6 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
"storyEnd" => text.len(),
other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
};
s.typing_format = None;
let stm = s.doc_mut()?;
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
stm.revision += 1;
@ -969,23 +782,6 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
v
}
/// Character formatting chosen at a bare caret in text: applied to what is typed there next.
#[derive(Clone, Debug)]
pub(crate) struct TypingFormat {
doc: u64,
at: TextSel,
attrs: CharAttrs,
/// Overrides removed (`null` in `type.char`).
cleared: Vec<String>,
}
/// The typing format for the active document's caret, if it was chosen at this caret.
fn typing_format(s: &Session) -> Option<&TypingFormat> {
let st = s.active()?;
let t = st.selection.text?;
s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range())
}
fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some());
let on = match p.get("on").and_then(Value::as_bool) {
@ -1045,50 +841,32 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
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.
if let [t] = targets.as_slice()
&& t.range.is_empty()
&& let Some(at) = s.doc()?.selection.text
&& s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty())
{
let doc = s.doc()?.uid;
let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] });
f.attrs.merge(&a);
f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k));
f.cleared.extend(cleared);
s.typing_format = Some(f);
return ok();
}
s.edit(|d, _| {
apply_char_attrs(d, &targets, &a, &cleared);
for t in &targets {
let r = &t.range;
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
if r.is_empty() {
// Caret: change the typing format (the empty run at the caret).
let pos = r.start;
if st.is_empty() {
st.chars[0].format.over.merge(&a);
st.rev += 1;
} else {
let _ = pos;
}
continue;
}
st.format_chars(r.clone(), |f| {
f.over.merge(&a);
for k in &cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
ok()
})
}
/// Character attributes `a` (and the overrides `cleared` removed) over each target's text.
pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) {
for t in targets {
let r = &t.range;
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
if r.is_empty() {
// An empty story: its one (empty) run is the format typing takes.
if st.is_empty()
&& let Some(run) = st.chars.first_mut()
{
run.format.over.merge(a);
st.rev += 1;
}
continue;
}
st.format_chars(r.clone(), |f| {
f.over.merge(a);
for k in cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
}
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
let empty = serde_json::Map::new();
let obj = attrs.as_object().unwrap_or(&empty);
@ -1192,17 +970,6 @@ 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);
@ -1211,176 +978,10 @@ 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 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());
let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) };
let cp = st.doc.styles.resolve_char(&base, cf);
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
"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) {
cf.over.merge(&f.attrs);
for k in &f.cleared {
let _ = cf.over.set_json(k, &Value::Null);
}
}
/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or
/// off as asked, else the opposite of the first selected character.
fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
let id = if bold { "type.bold" } else { "type.italic" };
let cur = selection_attrs(s, &json!({}))?;
let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string();
let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string();
// The document's own fonts count too.
let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope);
let (weight, italic) = db.traits_of(&family, &style);
let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic });
let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) };
let targets = format_targets(s);
// A caret: the linked style of the format there, for the next typing.
if let [t] = targets.as_slice()
&& t.range.is_empty()
{
let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?;
format_chars(s, &json!({"fontStyle": linked}))?;
return Ok(json!({"on": on, "fontStyle": linked}));
}
let mut changed = 0;
let r = s.edit(|d, _| {
let styles = d.styles.clone();
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
for (pi, pr) in st.para_ranges().into_iter().enumerate() {
let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end));
if a >= b {
continue;
}
let Some(pf) = st.paras.get(pi) else { continue };
let (_, pc) = styles.resolve_para(pf);
st.format_chars(a..b, |f| {
let cp = styles.resolve_char(&pc, f);
if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic)
&& linked != cp.font_style
{
f.over.font_style = Some(linked);
changed += 1;
}
});
}
}
// No undo step for nothing.
if changed == 0 {
return Err(bad(id, "nothing to change"));
}
ok()
});
r?;
Ok(json!({"on": on, "changed": changed}))
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
}
/// Our own filler text (not Adobe's).
@ -1553,37 +1154,6 @@ 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();
@ -1682,202 +1252,6 @@ mod nested_style_tests {
}
}
#[cfg(test)]
mod multi_click_tests {
use serde_json::{Value, json};
use crate::Session;
#[test]
fn moving_the_caret_drops_the_pending_typing_format() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
let pending = |s: &mut Session| {
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
};
// Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the
// same caret is in the text's own format.
let moves: Vec<Box<dyn Fn(&mut Session)>> = vec![
Box::new(|s| {
s.execute("text.move", &json!({"dir": "right"})).unwrap();
s.execute("text.move", &json!({"dir": "left"})).unwrap();
}),
Box::new(|s| {
s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
}),
Box::new(|s| {
s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
}),
];
for m in &moves {
pending(&mut s);
m(&mut s);
s.execute("text.insert", &json!({"text": "x"})).unwrap();
assert_eq!(size_at(&s, 6), None);
s.execute("edit.undo", &json!({})).unwrap();
}
// Without a move it applies.
pending(&mut s);
s.execute("text.insert", &json!({"text": "x"})).unwrap();
assert_eq!(size_at(&s, 6), Some(30.0));
}
#[test]
fn word_selection_keeps_grapheme_clusters_whole() {
use super::word_or_spaces_at as word;
// Decomposed "café": the combining acute belongs to the word, wherever the click lands.
let s = "un cafe\u{301} noir";
let cafe = (3, 3 + "cafe\u{301}".len());
assert_eq!(word(s, 4), cafe);
assert_eq!(word(s, 7), cafe, "on the combining mark");
// Devanagari with a virama (U+094D) and a dependent vowel sign.
let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x";
let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len();
assert_eq!(word(s, 0), (0, w));
assert_eq!(word(s, 9), (0, w), "on the virama");
// An emoji ZWJ sequence and a variation selector stay one character.
let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}";
let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len());
assert_eq!(word(s, 2), e);
assert_eq!(word(s, 6), e, "on the joiner");
assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole");
// Empty paragraphs select nothing.
assert_eq!(word("a\n\nb", 2), (2, 2));
}
#[test]
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = "Hello brave world.\nSecond paragraph here.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
// Spread point over the right half of the glyph at byte `b`.
let at = |s: &Session, b: usize| {
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap();
json!([g.x + g.adv * 0.75, l.baseline - 3.0])
};
let mut select = |unit: &str, b: usize| {
let p = at(&s, b);
let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap();
(r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize)
};
assert_eq!(select("word", 1), (0, 5), "inside a word");
assert_eq!(select("word", 4), (0, 5), "its last letter");
assert_eq!(select("word", 5), (5, 7), "the run of spaces after it");
assert_eq!(select("word", 18), (18, 19), "punctuation alone");
assert_eq!(select("line", 9), (0, 19));
assert_eq!(select("paragraph", 9), (0, 20), "with its return");
assert_eq!(select("paragraph", 22), (20, text.len()));
assert_eq!(select("story", 9), (0, text.len()));
// A second click inside the selected word selects again rather than starting a text drag.
s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap();
let p = at(&s, 9);
s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (0, 19));
s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it");
// The older word command agrees.
let p = at(&s, 8);
s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (7, 12));
assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err());
assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point");
}
}
#[cfg(test)]
mod style_link_tests {
use serde_json::json;
use crate::Session;
#[test]
fn bold_and_italic_follow_the_family_and_toggle() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap();
let sid = r["story"].as_u64().unwrap();
let style_at = |s: &mut Session, b: usize| {
s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap();
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string()
};
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
s.execute("type.italic", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Italic");
// Bold over italic and plain text: each run takes its own linked style.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Bold Italic");
assert_eq!(style_at(&mut s, 7), "Bold");
// Again: off, back to each run's regular weight.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Italic");
assert_eq!(style_at(&mut s, 7), "Regular");
// Nothing to change: an error and no undo step.
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap();
let steps = s.doc().unwrap().history.undo.len();
assert!(s.execute("type.bold", &json!({"on": false})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// At a caret the style is for what is typed next.
let end = "plain words here".len();
s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold");
s.execute("text.insert", &json!({"text": " now"})).unwrap();
assert_eq!(style_at(&mut s, end + 1), "Bold");
assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged");
// Moving the caret forgets it.
s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0);
}
}
#[cfg(test)]
mod text_color_tests {
use serde_json::json;
use crate::Session;
#[test]
fn unnamed_colours_go_to_text_when_asked() {
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": "colour me", "caret": false})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
s.execute("selection.set", &json!({"ids": [fid]})).unwrap();
let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
let before = fill_of_frame(&s);
let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap();
let red = r["swatch"].as_str().unwrap().to_string();
assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill");
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str()));
// A text selection takes it without asking.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap();
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str());
assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour");
}
}
#[cfg(test)]
mod drag_text_tests {
use serde_json::json;
@ -2522,44 +1896,3 @@ 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()));
}
}

View File

@ -1,181 +0,0 @@
//! 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);
}

View File

@ -1,396 +0,0 @@
//! Threading text frames: thread a frame after another (their stories merge), break a thread,
//! take a frame out of its thread. The Selection tool's loaded text cursor runs these.
use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param};
use crate::{Result, Session};
/// Pages Shift-click autoflow may add before it gives up.
const AUTOFLOW_MAX_PAGES: usize = 2000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"text.thread",
"Thread Text Frames",
[],
None,
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
cmd!(
"text.unthread",
"Break Thread",
[],
None,
"{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it",
has_doc,
unthread
),
cmd!(
"text.removeFromThread",
"Remove Frame from Thread",
[],
None,
"{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty",
has_doc,
remove_from_thread
),
]
}
/// `frame` param, or the first selected text frame.
fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
let st = s.doc()?;
let id = id_param(p, "frame")
.or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame())))
.ok_or_else(|| bad(cmd, "no text frame given or selected"))?;
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) };
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return Err(bad("text.thread", "rect must be finite"));
}
let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1));
let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1));
Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5))))
}
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 autoflow = bool_or(p, "autoflow", false);
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
let mut new_frame = || -> Result<ItemId> {
let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?;
if d.layer(layer).is_none_or(|l| l.locked || !l.visible) {
return Err(bad("text.thread", "the active layer is locked or hidden"));
}
let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
Ok(d.add_text_frame(sr, r, layer, "", para)?.0)
};
let (from, to, joined) = match (from, to) {
(Some(f), Some(t)) if rect.is_none() => (f, t, t),
(Some(f), None) => {
let t = new_frame()?;
(f, t, t)
}
(None, Some(t)) => {
let f = new_frame()?;
(f, t, f)
}
_ => return Err(bad("text.thread", "give two of from, to and rect")),
};
d.check_thread(from, to)?;
d.thread_keeping_story(from, to)?;
let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?;
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 };
*sel = Selection::items(vec![joined]);
Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages}))
})
}
fn overset(d: &Document, sid: StoryId) -> bool {
designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some()
}
fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.unthread";
let frame = frame_param(s, p, CMD)?;
let d0 = &s.doc()?.doc;
let at = match str_param(p, "side").unwrap_or("after") {
"after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?,
"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"))?;
Ok(json!({"story": sid.0, "overset": overset(d, sid)}))
})
}
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!({}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s
}
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) {
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap();
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
}
fn story(s: &mut Session, frame: u64) -> serde_json::Value {
s.execute("story.get", &json!({"frame": frame})).unwrap()
}
fn frames(s: &mut Session, frame: u64) -> Vec<u64> {
story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
const LONG: &str = "Threaded text keeps flowing from one frame into the next one. ";
#[test]
fn thread_into_an_empty_frame_and_undo() {
let mut s = session();
let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10));
let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset");
let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sid));
assert_eq!(frames(&mut s, a), [a, b]);
let d = &s.doc().unwrap().doc;
assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]);
// The text now reaches the second frame.
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
}
#[test]
fn thread_into_a_text_frame_merges_its_story() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First");
let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
let st = story(&mut s, b);
assert_eq!(st["id"].as_u64(), Some(sa));
assert_eq!(st["text"], "First\nSecond");
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn thread_into_the_middle_of_a_thread() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], "");
s.execute("text.thread", &json!({"from": a, "to": c})).unwrap();
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn bad_threads_are_errors_and_change_nothing() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a");
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b");
let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x");
let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y");
let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap();
let group = {
let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone();
let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [p, q]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.doc().unwrap().selection.items[0].0
};
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": x, "to": y})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let doc = s.doc().unwrap().doc.clone();
for p in [
json!({"from": a, "to": a}),
json!({"from": a, "to": g}),
json!({"from": g, "to": a}),
json!({"from": a, "to": group}),
// Earlier in the same thread.
json!({"from": b, "to": a}),
// Text already flows into y from x.
json!({"from": a, "to": y}),
json!({"from": a, "to": 999_999}),
json!({"from": a}),
json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}),
json!({"rect": [0, 0, 10, 10]}),
json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}),
json!({"from": "a", "to": [1]}),
json!([1, 2]),
] {
assert!(s.execute("text.thread", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(*s.doc().unwrap().doc, *doc);
}
#[test]
fn locked_frames_and_layers_refuse_threads() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
let layer = s.doc().unwrap().active_layer.0;
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.lock", &json!({"ids": [b]})).unwrap();
assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame");
assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame");
s.execute("edit.undo", &json!({})).unwrap();
for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] {
let mut p = json!({"id": layer});
p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone());
s.execute("layer.set", &p).unwrap();
let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) };
assert!(s.execute("text.thread", &t).is_err(), "{p} {t}");
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(frames(&mut s, a), [a]);
}
#[test]
fn thread_a_new_frame_after_or_in_front() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap();
let n = r["to"].as_u64().unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]);
let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds();
assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0));
// In front of the first frame: the story keeps its id and text.
let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap();
let m = r["from"].as_u64().unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(frames(&mut s, a), [m, a, n]);
assert_eq!(story(&mut s, a)["text"], LONG.repeat(8));
s.doc().unwrap().doc.check().unwrap();
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
}
use designcraft_geom::Rect;
#[test]
fn thread_with_autoflow_adds_pages_until_the_text_fits() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false);
assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}");
assert!(frames(&mut s, a).len() >= 3);
s.doc().unwrap().doc.check().unwrap();
// One undo removes the frames and the pages.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
}
#[test]
fn autoflow_from_a_parent_frame_is_an_error() {
let mut s = session();
let parent = json!({"kind": "parent", "index": 0});
let r = s.execute(
"frame.create",
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}),
);
let a = r.unwrap()["id"].as_u64().unwrap();
let undo = s.doc().unwrap().history.undo.len();
let doc = s.doc().unwrap().doc.clone();
let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err();
assert!(e.to_string().contains("text.thread"), "{e}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(*s.doc().unwrap().doc, *doc);
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
}
#[test]
fn unthread_after_and_before() {
let mut s = session();
let text = LONG.repeat(12);
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
// Break after the first frame: b and c stay threaded to each other, empty.
let r = s.execute("text.unthread", &json!({"frame": a})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(r["overset"], true);
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(frames(&mut s, b), [b, c]);
assert_eq!(story(&mut s, b)["text"], "");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
// Break before the last frame, with the frame selected.
s.execute("selection.set", &json!({"ids": [c]})).unwrap();
s.execute("text.unthread", &json!({"side": "before"})).unwrap();
assert_eq!(frames(&mut s, a), [a, b]);
assert_eq!(frames(&mut s, c), [c]);
// Nothing to break.
assert!(s.execute("text.unthread", &json!({"frame": b})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err());
}
#[test]
fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() {
let mut s = session();
let text = LONG.repeat(12);
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
s.execute("text.removeFromThread", &json!({"frame": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(story(&mut s, b)["text"], "");
assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
s.doc().unwrap().doc.check().unwrap();
}
}

View File

@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, text.as_bytes())?;
std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"text": text, "bytes": text.len()})),

View File

@ -7,8 +7,6 @@
#![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;
@ -93,9 +91,6 @@ 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.
@ -134,7 +129,6 @@ 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,
@ -183,8 +177,6 @@ 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,
@ -241,7 +233,6 @@ 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,
@ -276,8 +267,6 @@ pub struct Session {
pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool),
/// Drag and drop text editing: the press landed in the selected text.
pub(crate) text_drag: bool,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// The open Object Library (File › New / Open Library).
pub library: Option<cmd::library::Library>,
/// The open book (File › Open Book).
@ -309,7 +298,6 @@ impl Session {
loaded: None,
transforms: Default::default(),
text_drag: false,
typing_format: None,
library: None,
conveyor: Vec::new(),
book: None,
@ -386,9 +374,8 @@ impl Session {
self.stop_data_preview();
}
#[cfg(not(target_arch = "wasm32"))]
{
let recovery_dir = self.recovery_dir.clone();
recovery::discard_document(self, i, recovery_dir.as_deref());
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
recovery::discard(dir, d.uid);
}
if i < self.docs.len() {
self.docs.remove(i);
@ -478,7 +465,7 @@ impl Session {
}
let mut d = (*st.doc).clone();
if overset {
let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow");
let _ = cmd::place_text_autoflow(&mut d, sid, 500);
} else {
// Drop empty trailing frames on the last pages (one frame, nothing else there).
while let Some(story) = d.story(sid) {

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,7 @@
//!
//! **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. A leading byte order mark and CRLF line endings are
//! accepted.
//! lines starting with `#` or `//` are ignored.
use serde_json::{Value, json};
@ -23,9 +22,7 @@ pub struct Step {
/// Parse script text (see the module docs).
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
// 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();
let t = text.trim();
if t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
return v
@ -177,17 +174,6 @@ 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"})];

View File

@ -569,103 +569,3 @@ fn commands_take_non_object_params_without_panicking() {
assert!(r.is_ok(), "{p}: {r:?}");
}
}
/// Load the text from a frame's out port with the Selection tool and click on the pasteboard:
/// a new frame is threaded there and the overset text flows into it.
#[test]
fn threading_with_the_loaded_text_cursor_on_the_sample() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story");
// Break the thread so the first frame is overset, and select it.
s.execute("text.unthread", &json!({"frame": first.0})).unwrap();
assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset());
s.execute("selection.set", &json!({"ids": [first.0]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let layout = s.layout();
let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap();
let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y);
s.pointer(&ev(PointerKind::Down, port), v).unwrap();
s.pointer(&ev(PointerKind::Up, port), v).unwrap();
assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText);
// On the pasteboard left of the first spread.
let b = layout.slots[0].bounds;
let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0);
let undo = s.doc().unwrap().history.undo.len();
s.pointer(&ev(PointerKind::Down, at), v).unwrap();
s.pointer(&ev(PointerKind::Up, at), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
let frames = st.doc.story(sid).unwrap().frames.clone();
assert_eq!(frames.len(), 2);
let new = frames[1];
assert_eq!(st.selection.items, [new]);
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame");
let nb = s.doc().unwrap().doc.item(new).unwrap().bounds();
assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}");
}
/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units).
fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) {
use designcraft_tools::{PointerEvent, PointerKind};
let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at);
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap();
}
#[test]
fn text_frame_resize_snaps_to_the_margins() {
use designcraft_geom::{Point, Vec2};
for zoom in [1.0, 0.5] {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0);
s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(zoom);
let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
// Right handle to 1.5 pt short of the right margin.
drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1);
// Bottom handle to 1.5 pt past the bottom margin.
drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1);
// Top-left corner handle onto the top and left margins.
drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}");
}
}
#[test]
fn type_tool_frames_snap_to_the_margins() {
use designcraft_geom::Point;
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
s.set_tool("type");
let v = ViewInfo::at_zoom(1.0);
let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y));
// From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin.
let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5);
let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5);
let before = s.doc().unwrap().doc.spreads[0].items.len();
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
let items = &s.doc().unwrap().doc.spreads[0].items;
assert_eq!(items.len(), before + 1, "a frame was drawn");
let r = items.last().unwrap().bounds();
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
}

View File

@ -91,91 +91,6 @@ fn magazine_round_trips_through_idml() {
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
}
/// A one-page IDML package: two threaded frames side by side, 19 lines each (10 pt on 12 pt),
/// paragraphs of `lines` forced-break lines in style `Body`, which carries `body_attrs`.
fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> {
use std::io::Write;
let paras: String = lines
.iter()
.enumerate()
.map(|(p, &n)| {
let text = (0..n).map(|k| format!("P{p} line {k}")).collect::<Vec<_>>().join("\u{2028}");
format!(
r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content>{}</CharacterStyleRange></ParagraphStyleRange>"#,
if p + 1 < lines.len() { "<Br/>" } else { "" }
)
})
.collect();
let frame = |me: &str, x: f64, prev: &str, next: &str| {
let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{me}" ParentStory="s1" PreviousTextFrame="{prev}" NextTextFrame="{next}" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
<DocumentPreference PageWidth="450" PageHeight="312" FacingPages="false"/>
<RootCharacterStyleGroup Self="rc"><CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/></RootCharacterStyleGroup>
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" PointSize="10" {body_attrs}><Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn><Leading type="unit">12</Leading></Properties></ParagraphStyle>
</RootParagraphStyleGroup>
<Story Self="s1">{paras}</Story>
<Spread Self="sp1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 312 450" ItemTransform="1 0 0 1 0 0"/>{}{}</Spread>
</Document>"#,
frame("t1", 36.0, "n", "t2"),
frame("t2", 234.0, "t1", "n")
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("mimetype", stored).unwrap();
w.write_all(b"application/vnd.adobe.indesign-idml-package").unwrap();
w.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(designmap.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}
/// Lines of paragraph `para` in each frame of the story.
fn para_lines_per_frame(d: &Document, para: usize) -> Vec<usize> {
let sid = *d.stories.keys().next().unwrap();
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames.iter().map(|f| f.lines.iter().filter(|l| l.para == para).count()).collect()
}
/// Keep Lines Together without a mode in the IDML: InDesign's default, at start/end of paragraph.
/// A 10-line paragraph starting 6 lines above the first frame's bottom splits 6/4.
#[test]
fn idml_keep_lines_without_mode_split_at_start_and_end() {
let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#;
let d = designcraft_idml::import_idml(&keep_lines_idml(keep, &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 0), [13, 0]);
assert_eq!(para_lines_per_frame(&d, 1), [6, 4]);
// All lines in paragraph, written explicitly: the paragraph moves whole.
let d = designcraft_idml::import_idml(&keep_lines_idml(&format!(r#"{keep} KeepAllLinesTogether="true""#), &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 1), [0, 10]);
}
/// A document saved in format 1 with Keep Lines Together and no mode still keeps all lines
/// together after loading; saved now, it keeps lines at start/end of paragraph.
#[test]
fn format_1_keep_lines_without_mode_lays_out_all_lines() {
use std::io::Write;
let d = designcraft_idml::import_idml(&keep_lines_idml(r#"KeepLinesTogether="true""#, &[13, 10, 2])).unwrap();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
w.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
let v1 = designcraft_format::load(&w.finish().unwrap().into_inner()).unwrap();
assert_eq!(para_lines_per_frame(&v1, 1), [0, 10]);
let now = designcraft_format::load(&designcraft_format::save(&d).unwrap()).unwrap();
assert_eq!(para_lines_per_frame(&now, 1), [6, 4]);
}
#[test]
fn idml_commands() {
let mut s = Session::new();
@ -336,30 +251,3 @@ fn named_lists_round_trip_through_idml() {
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
}
#[test]
fn type_para_sets_a_drop_cap_that_survives_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap();
let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial", "dropCapAlignLeft": false}})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default());
let lines = &cs.frames[0].lines;
let dc = &lines[0].glyphs[0];
assert_eq!(dc.byte, 0);
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline");
assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1));
let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial")));
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false)));
// The drop cap's character style goes through IDML as a leading Dropcap nested style.
assert_eq!(p.drop_cap_style.as_deref(), Some("Initial"));
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
}

View File

@ -399,125 +399,3 @@ 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);
}
}
}

View File

@ -1,188 +0,0 @@
#![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);
}

View File

@ -1,274 +0,0 @@
//! 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);
}
}

View File

@ -1,87 +0,0 @@
#![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");
}

View File

@ -1,128 +0,0 @@
//! 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);
}
}
}

View File

@ -1,217 +0,0 @@
//! 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);
}

View File

@ -1,34 +0,0 @@
//! 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);
}
}

View File

@ -1,68 +0,0 @@
#![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());
}

View File

@ -163,42 +163,3 @@ 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:?}");
}

View File

@ -1,277 +0,0 @@
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)
);
}
}
}
}

View File

@ -1,223 +0,0 @@
//! 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);
}
}
}

View File

@ -322,10 +322,7 @@ 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 (tag, class) = export_mapping(st, &ps.name, false, &pc);
if let Some(selector) = class_selector(tag, &class) {
let _ = writeln!(css, "{selector} {{ {rule}}}");
}
let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]);
}
for cs in &st.character {
if cs.name == story::NO_CHAR_STYLE {
@ -350,10 +347,7 @@ pub fn stylesheet(doc: &Document) -> String {
if let Some(sz) = c.size {
let _ = write!(rule, "font-size: {:.3}em; ", sz / base);
}
let (tag, class) = export_mapping(st, &cs.name, true, &cc);
if let Some(selector) = class_selector(tag, &class) {
let _ = writeln!(css, "{selector} {{ {rule}}}");
}
let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]);
}
css
}
@ -392,39 +386,6 @@ 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() };
@ -433,7 +394,10 @@ 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 (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc);
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 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 { "" });
@ -463,11 +427,17 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
} else {
story_markup(raw)
};
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)))
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))
} else {
("span", String::new())
String::new()
};
let style = override_css(doc, &f.over);
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };

View File

@ -1,138 +0,0 @@
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;"));
}

View File

@ -103,14 +103,10 @@ pub struct FontFace {
pub upem: f64,
/// Ascender in font units (positive = up).
pub ascent: f64,
/// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the
/// Ascent first baseline offset.
pub typo_ascent: f64,
/// Descender in font units (positive = down).
pub descent: f64,
/// Cap height and x height in font units (without plausible OS/2 values, the cap height is the
/// top of the H, and either is estimated from the ascent): the Cap Height and x Height first
/// baseline offsets.
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
/// values).
pub cap_height: f64,
pub x_height: f64,
pub shaper: harfrust::ShaperData,
@ -122,11 +118,9 @@ pub struct FontFace {
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
/// The ideographic em box, read on first use.
em: std::sync::OnceLock<((f64, f64), bool)>,
em: std::sync::OnceLock<(f64, f64)>,
/// 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
@ -194,32 +188,6 @@ 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()
}
@ -291,17 +259,11 @@ impl FontFace {
pub fn v_origin(&self, gid: u32) -> f64 {
crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid))
}
/// The ideographic em box (top, bottom) in font units, y up, one em tall (Japanese fonts:
/// typically 880, -120): `BASE` `idtp`/`ideo`; else, for a CJK font, from the OS/2 typo
/// ascender when the typo ascender and descender span one em; else centred on half the cap
/// height (OS/2, else the `H` glyph's top); else 0.88 em above the baseline.
/// The ideographic em box (top, bottom) in font units, y up (Japanese fonts: typically 880,
/// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and
/// descender, the last two centred on one em.
pub fn em_box(&self) -> (f64, f64) {
self.em.get_or_init(|| crate::vertical::em_box(self)).0
}
/// [`Self::em_box`] when the font's tables give it, `None` for the 0.88 em fallback.
pub fn declared_em_box(&self) -> Option<(f64, f64)> {
let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self));
declared.then_some(b)
*self.em.get_or_init(|| crate::vertical::em_box(self))
}
/// The font menu group the font is for and, for a CJK group, its family name in that language
/// (see [`crate::FamilyInfo`]); read from the font once.
@ -473,29 +435,6 @@ 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] {
@ -738,7 +677,6 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect();
let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) };
let m = f.metrics(Size::unscaled(), &location);
let upem = m.units_per_em.max(1) as f64;
let a = f.attributes();
let hb = harfrust::FontRef::from_index(data, index).ok()?;
let shaper = harfrust::ShaperData::new(&hb);
@ -756,19 +694,11 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
style,
weight,
italic,
upem,
upem: m.units_per_em.max(1) as f64,
ascent: m.ascent as f64,
typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64),
descent: -(m.descent as f64),
// Fonts with an OS/2 table older than version 2 don't declare these: measure them from
// flat-topped letters before falling back to a share of the ascent, which can be far off
// (drop caps are sized from the cap height).
cap_height: plausible(m.cap_height.map(f64::from), upem)
.or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem))
.unwrap_or(m.ascent as f64 * 0.72),
x_height: plausible(m.x_height.map(f64::from), upem)
.or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem))
.unwrap_or(m.ascent as f64 * 0.5),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
shaper,
coords,
location,
@ -779,58 +709,9 @@ 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(),
})
}
/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em.
pub(crate) fn plausible(v: Option<f64>, upem: f64) -> Option<f64> {
v.filter(|v| *v > 0.0 && *v <= 4.0 * upem)
}
/// The top of the first of `chars` the font has a non-empty outline for, in font units: the
/// highest point of its unhinted outline at `location`, if [`plausible`]. For flat-topped letters
/// that is the cap height (`H`) or x-height (`x`).
pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option<f64> {
struct Top(f32);
impl OutlinePen for Top {
fn move_to(&mut self, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn line_to(&mut self, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn close(&mut self) {}
}
let cmap = f.charmap();
let glyphs = f.outline_glyphs();
chars.iter().find_map(|&c| {
let g = glyphs.get(cmap.map(c)?)?;
let mut pen = Top(f32::NEG_INFINITY);
g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?;
plausible(Some(f64::from(pen.0)), upem)
})
}
/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and
/// the value is [`plausible`].
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option<f64> {
use skrifa::raw::TableProvider;
let mut v = f.os2().ok()?.s_typo_ascender() as f64;
if !location.coords().is_empty()
&& let Ok(mvar) = f.mvar()
{
v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64());
}
plausible(Some(v), upem)
}
/// `family` without the font-format suffix layout apps append when a family is installed in
/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one.
fn without_format_suffix(family: &str) -> Option<&str> {
@ -953,16 +834,6 @@ impl FontDb {
self.scoped(0).styles(family)
}
/// The shared fonts' weight and italic of a style of `family` (see [`ScopedFonts::traits_of`]).
pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) {
self.scoped(0).traits_of(family, style)
}
/// Style linking among the shared fonts (see [`ScopedFonts::linked_style`]).
pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> {
self.scoped(0).linked_style(family, current, bold, italic)
}
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
/// every face was already present).
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
@ -1356,59 +1227,14 @@ impl ScopedFonts<'_> {
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
self.style_traits(family).into_iter().map(|t| t.2).collect()
}
/// Weight and italic of a style of `family`: from the font when it is known, else from the
/// style's name.
pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) {
let base = base_style(style);
self.style_traits(family)
.into_iter()
.find(|t| t.2.eq_ignore_ascii_case(base))
.map_or((style_weight(base), style_italic(base)), |(italic, weight, _)| (weight, italic))
}
/// Style linking: the style of `family` that sets bold (`bold`) and italic (`italic`) as asked
/// and keeps the other from `current` (Bold from Italic is Bold Italic). `None` when the
/// family has no such style.
pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> {
let (weight, cur_italic) = self.traits_of(family, current);
let want_italic = italic.unwrap_or(cur_italic);
let want_weight = match bold {
Some(true) => 700.0,
Some(false) => 400.0,
None => weight,
};
// Width words (Condensed, Extended…) of the current style are kept when the family allows.
const WIDTHS: &[&str] = &["condensed", "compressed", "narrow", "extended", "expanded", "wide"];
let widths = |s: &str| {
let n = norm(s);
WIDTHS.iter().filter(|w| n.contains(*w)).count()
};
let cur_widths = widths(current);
self.style_traits(family)
.into_iter()
.filter(|(i, w, _)| *i == want_italic && bold.is_none_or(|b| b == (*w >= 600.0)))
.min_by(|a, b| {
let key = |t: &(bool, f32, String)| ((t.1 - want_weight).abs(), usize::from(widths(&t.2) != cur_widths), t.2.len());
let (ka, kb) = (key(a), key(b));
ka.0.total_cmp(&kb.0).then(ka.1.cmp(&kb.1)).then(ka.2.cmp(&kb.2))
})
.map(|t| t.2)
}
/// `(italic, weight, name)` of each style of `family` (the document's, loaded, or installed),
/// Regular first, then by weight.
fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
let v = self.exact_style_traits(family);
let v = self.exact_styles(family);
match without_format_suffix(family) {
Some(base) if v.is_empty() => self.exact_style_traits(base),
Some(base) if v.is_empty() => self.exact_styles(base),
_ => v,
}
}
fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
fn exact_styles(&self, family: &str) -> Vec<String> {
let mut v: Vec<(bool, f32, String)> = self
.own()
.iter()
@ -1424,7 +1250,7 @@ impl ScopedFonts<'_> {
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
v.dedup_by(|a, b| a.2 == b.2);
v
v.into_iter().map(|t| t.2).collect()
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
@ -1451,43 +1277,6 @@ 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 {
@ -1586,12 +1375,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`]), 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.
/// 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.
pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option<Arc<FontFace>> {
if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c))
if let Some(chain) = cjk_chain(c, language)
&& let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude))
{
return Some(f);
@ -1643,53 +1432,6 @@ 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());
@ -1739,54 +1481,3 @@ mod tests_docfonts;
#[cfg(test)]
#[path = "tests_vertical.rs"]
mod tests_vertical;
#[cfg(test)]
mod tests_heights {
use super::*;
use crate::testing::{font_mapping, with_table_u16};
fn face_of(bytes: Vec<u8>) -> FontFace {
make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap()
}
/// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared).
fn undeclared(font: Vec<u8>) -> Vec<u8> {
with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap()
}
/// The top of Source Sans 3's glyph for `c` from its bounding box (font units).
fn bbox_top(c: char) -> f64 {
let f = skrifa::FontRef::new(BUNDLED[0]).unwrap();
f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64
}
/// A font that doesn't declare its cap height and x-height gets them measured from its
/// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that
/// declares them keeps them.
#[test]
fn undeclared_heights_are_measured_from_outlines() {
let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')];
for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] {
let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap()));
let (cap, x) = (bbox_top(cap), bbox_top(x));
assert!(cap > x && x > 0.0);
assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height);
assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height);
}
let declared = face_of(font_mapping("Heights", &glyphs).unwrap());
assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values");
// Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height.
let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap());
assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height));
}
/// Without any of the letters, or with only empty outlines, the heights fall back to a share
/// of the ascent.
#[test]
fn heights_without_measurable_letters_fall_back_to_the_ascent() {
for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] {
let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap()));
assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}");
}
}
}

View File

@ -1,191 +0,0 @@
//! 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");
}
}

View File

@ -9,7 +9,6 @@
mod fontdb;
mod group;
mod kind;
#[cfg(any(test, feature = "testing"))]
pub mod testing;
mod vertical;
@ -21,7 +20,6 @@ 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;
@ -349,22 +347,6 @@ mod tests {
}
}
#[test]
fn style_linking_finds_bold_and_italic_in_the_family() {
let db = FontDb::with_font_dirs(vec![]);
let link = |cur: &str, bold: Option<bool>, italic: Option<bool>| db.linked_style("Source Serif 4", cur, bold, italic);
assert_eq!(link("Regular", Some(true), None).as_deref(), Some("Bold"));
assert_eq!(link("Italic", Some(true), None).as_deref(), Some("Bold Italic"), "keeps the italic");
assert_eq!(link("Bold", None, Some(true)).as_deref(), Some("Bold Italic"), "keeps the weight");
assert_eq!(link("Bold Italic", Some(false), None).as_deref(), Some("Italic"));
assert_eq!(link("Bold Italic", None, Some(false)).as_deref(), Some("Bold"));
assert_eq!(link("Semibold", Some(false), None).as_deref(), Some("Regular"));
assert_eq!(db.traits_of("Source Serif 4", "Bold Italic"), (700.0, true));
// Source Sans 3 is bundled without a bold italic.
assert_eq!(db.linked_style("Source Sans 3", "Italic", Some(true), None), None);
assert_eq!(db.linked_style("No Such Family", "Regular", Some(true), None), None);
}
#[test]
fn shaping_produces_clusters_and_advances() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");

View File

@ -127,33 +127,3 @@ 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"));
}

View File

@ -126,23 +126,6 @@ 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");

View File

@ -6,7 +6,7 @@
use skrifa::raw::TableProvider;
use super::*;
use crate::testing::{font_mapping, font_with, with_code_pages, with_table_len, with_table_u16, with_vmtx, with_vorg};
use crate::testing::{font_mapping, font_with, with_table_len, with_table_u16, with_vmtx, with_vorg};
use crate::{ShapeContext, feature, shape_vertical, shape_with_context};
/// Text in Japanese.
@ -111,36 +111,14 @@ fn fonts_without_vmtx_use_the_em_box() {
assert_eq!(face.em_box(), (830.0, -170.0));
assert_eq!(face.v_advance(gid), face.upem);
assert_eq!(face.v_origin(gid), 830.0);
// Without BASE either: the typo ascender and descender (1000, -326) span more than one em, so
// the em box is centred on half the OS/2 cap height (660).
// Without BASE either: the OS/2 typo ascender and descender (1000, -326), centred on one em.
let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap());
assert_eq!(face.em_box(), (830.0, -170.0));
assert_eq!(face.declared_em_box(), Some((830.0, -170.0)));
// Typo ascender and descender one em apart: a CJK font (JIS code page) tops at the typo
// ascender; a Latin one (Source Sans 3's own code pages) still centres on the cap height.
let typo = |family: &str, code_pages: Option<u32>| {
let font = with_table_u16(font_with(family, &['一']).unwrap(), b"OS/2", 68, 860).unwrap();
let font = with_table_u16(font, b"OS/2", 70, u16::from_be_bytes((-140i16).to_be_bytes())).unwrap();
let font = match code_pages {
Some(r1) => with_code_pages(font, r1, 0).unwrap(),
None => font,
};
face_of(with_table_len(font, b"BASE", 0).unwrap())
};
let face = typo("Typo Em CJK", Some(1 << 17));
assert_ne!(face.menu_group().0, crate::FontGroup::Western);
assert_eq!(face.em_box(), (860.0, -140.0));
let face = typo("Typo Em Latin", None);
assert_eq!(face.menu_group().0, crate::FontGroup::Western);
assert_eq!(face.em_box(), (830.0, -170.0));
// Without OS/2: centred on half the H glyph's height (656).
let no_os2 = |font: Vec<u8>| face_of(with_table_len(with_table_len(font, b"BASE", 0).unwrap(), b"OS/2", 0).unwrap());
let face = no_os2(font_mapping("No OS2", &[('一', 'X'), ('H', 'H')]).unwrap());
assert_eq!(face.em_box(), (828.0, -172.0));
// Without an H either: 0.88 em above the baseline, which the font doesn't declare.
let face = no_os2(font_with("No OS2 No H", &['一']).unwrap());
assert_eq!(face.em_box(), (880.0, -120.0));
assert_eq!(face.declared_em_box(), None);
assert_eq!(face.em_box(), (837.0, -163.0));
// Without OS/2: the ascender and descender, the same way.
let face = face_of(with_table_len(with_table_len(font_with("No OS2", &['一']).unwrap(), b"BASE", 0).unwrap(), b"OS/2", 0).unwrap());
let (top, bottom) = face.em_box();
assert_eq!(top - bottom, face.upem);
assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}");
}
#[test]

View File

@ -1,6 +1,6 @@
//! Vertical metrics for upright glyphs in vertical text: advances down the line (`vmtx`), vertical
//! origins (`VORG`, else `vmtx` and the glyph's top) and the ideographic em box (`BASE`, else
//! OS/2 metrics). Fonts without them fall back to the em box: one em down the line, the origin at its top.
//! OS/2). Fonts without them fall back to the em box: one em down the line, the origin at its top.
use skrifa::instance::Size;
use skrifa::metrics::GlyphMetrics;
@ -53,30 +53,25 @@ impl<'a> VMetrics<'a> {
}
}
/// The ideographic em box (top, bottom) in font units, y up, one em tall: `BASE` `idtp`/`ideo`;
/// else, for a CJK font (its [`crate::FontGroup`] isn't Western), topped by the OS/2 typo ascender
/// when the typo ascender and descender span exactly one em; else centred on half the cap height
/// (OS/2 `sCapHeight`, else the `H` glyph's top); else 0.88 em above the baseline. The flag is
/// false for that last fallback.
pub(crate) fn em_box(face: &FontFace) -> ((f64, f64), bool) {
/// The ideographic em box (top, bottom) in font units, y up: `BASE` `idtp`/`ideo`, else the OS/2
/// typo ascender and descender, else the ascender and descender; the last two centred on one em.
pub(crate) fn em_box(face: &FontFace) -> (f64, f64) {
let upem = face.upem;
let fallback = ((upem * 0.88, upem * -0.12), false);
let Some(font) = face.skrifa() else { return fallback };
if let Some(b) = base_em_box(&font, upem) {
return (b, true);
let font = face.skrifa();
if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) {
return b;
}
// Latin fonts with a one-em typo box (DejaVu) still centre on the cap height.
let cjk = face.menu_group().0 != crate::FontGroup::Western;
let typo_top = font.os2().ok().filter(|_| cjk).and_then(|t| {
let (asc, desc) = (f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender()));
(asc - desc == upem && asc > 0.0 && asc <= upem).then_some(asc)
});
let top = typo_top.or_else(|| {
let declared = font.metrics(Size::unscaled(), face.location()).cap_height.map(f64::from);
let cap = crate::fontdb::plausible(declared, upem).or_else(|| crate::fontdb::measured_top(&font, face.location(), &['H'], upem))?;
Some((upem + cap) / 2.0)
});
top.map_or(fallback, |top| ((top, top - upem), true))
let centred = |asc: f64, desc: f64| {
let span = asc - desc;
(span > 0.0 && span.is_finite()).then(|| {
let top = asc - (span - upem) / 2.0;
(top, top - upem)
})
};
font.and_then(|f| f.os2().ok())
.and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender())))
.or_else(|| centred(face.ascent, -face.descent))
.unwrap_or((upem * 0.88, upem * -0.12))
}
/// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default

View File

@ -6,8 +6,7 @@
//! the ones the document references (see [`Document::compact_assets`])
//! - `meta.json`: format version and generator
//!
//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents
//! from older format versions are upgraded on load so they lay out as they did.
//! Older single-file JSON documents (assets as base64 in `assetData`) still open.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@ -15,11 +14,11 @@ use std::collections::{HashMap, HashSet};
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use designcraft_doc::{AssetId, Document, ParaAttrs, Story};
use designcraft_doc::{AssetId, Document};
use serde_json::{Value, json};
pub const MIME: &str = "application/vnd.designcraft+zip";
pub const VERSION: u32 = 2;
pub const VERSION: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum FormatError {
@ -94,16 +93,16 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
f.read_to_end(&mut v).ok()?;
Some(v)
};
let version = read(&mut z, "meta.json")
.and_then(|meta| serde_json::from_slice::<Value>(&meta).ok())
.and_then(|m| m.get("version").and_then(Value::as_u64))
.map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
if version > VERSION {
return Err(FormatError::TooNew(version));
if let Some(meta) = read(&mut z, "meta.json")
&& let Ok(m) = serde_json::from_slice::<Value>(&meta)
&& let Some(v) = m.get("version").and_then(Value::as_u64)
&& v as u32 > VERSION
{
return Err(FormatError::TooNew(v as u32));
}
let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?;
let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?;
upgrade_json(&mut v);
upgrade(&mut v);
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?;
let names: Vec<String> = z.file_names().map(str::to_string).collect();
// Files saved with one copy per placement: identical copies share one buffer as they are
@ -125,62 +124,14 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
Arc::make_mut(a).data = data;
}
}
upgrade(&mut doc, version);
doc.merge_duplicate_assets();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
/// Bring a document saved in format `from` up to [`VERSION`].
fn upgrade(doc: &mut Document, from: u32) {
if from < 2 {
// Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph, and drop
// caps without Align Left Edge.
pin_para_default(doc, |a| &mut a.keep_all_lines, true);
pin_para_default(doc, |a| &mut a.drop_cap_align_left, false);
}
}
/// Pin a paragraph attribute to the value an older format resolved it to where nothing set it:
/// at the start of each paragraph style chain that leaves it unset, and on paragraphs whose style
/// is missing.
fn pin_para_default<T: Clone>(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option<T>, old: T) {
let unset = |a: &ParaAttrs| field(&mut a.clone()).is_none();
let roots: std::collections::HashSet<String> = doc
.styles
.paragraph
.iter()
.filter_map(|s| {
let chain = doc.styles.para_chain(&s.name);
if chain.iter().all(|c| unset(&c.para)) { chain.first().map(|c| c.name.clone()) } else { None }
})
.collect();
if !roots.is_empty() {
for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) {
*field(&mut s.para) = Some(old.clone());
}
}
let styles = doc.styles.clone();
let mut pin = |st: &mut Story| {
for p in st.paras.iter_mut().filter(|p| styles.para(&p.style).is_none()) {
let v = field(&mut p.para);
if v.is_none() {
*v = Some(old.clone());
}
}
};
for st in doc.stories.values_mut() {
let st = Arc::make_mut(st);
st.for_each_text_mut(&mut pin);
for n in &mut st.endnotes {
Arc::make_mut(n).text.for_each_text_mut(&mut pin);
}
}
}
fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?;
upgrade_json(&mut v);
upgrade(&mut v);
let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?;
if let Some(data) = v.get("assetData").and_then(Value::as_object) {
for (k, s) in data {
@ -191,7 +142,6 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
}
}
}
upgrade(&mut doc, 1);
doc.merge_duplicate_assets();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
@ -200,7 +150,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
/// Bring older documents' JSON up to the current model. Vertical Type was a text frame option
/// (`content.options.vertical`); it is the story's direction, which a story takes from its first
/// frame.
fn upgrade_json(doc: &mut Value) {
fn upgrade(doc: &mut Value) {
let mut vertical = HashSet::new();
vertical_frames(doc, &mut vertical, 0);
if vertical.is_empty() {
@ -343,122 +293,6 @@ mod tests {
assert_eq!(back.stories, d.stories);
}
/// `doc` as format 1 wrote it.
fn save_v1(doc: &Document) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(&serde_json::to_vec(doc).unwrap()).unwrap();
z.finish().unwrap().into_inner()
}
fn para_style(name: &str, based_on: Option<&str>, keep_all_lines: Option<bool>) -> designcraft_doc::ParagraphStyle {
designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: based_on.map(str::to_string),
next_style: None,
para: designcraft_doc::ParaAttrs { keep_lines_together: Some(true), keep_all_lines, ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
}
}
/// Format 1 resolved an unset Keep Lines Together mode as all lines: those documents still
/// do after loading, wherever the mode comes from. New documents keep lines at start/end.
#[test]
fn format_1_keep_lines_without_mode_stays_all_lines() {
let mut d = Document::new(&NewDocument::default());
let st = d.styles_mut();
st.paragraph.push(para_style("Root", None, None));
st.paragraph.push(para_style("Child", Some("Root"), None));
st.paragraph.push(para_style("Explicit", None, Some(false)));
st.paragraph.push(para_style("Under explicit", Some("Explicit"), None));
st.paragraph.push(para_style("Loop A", Some("Loop B"), None));
st.paragraph.push(para_style("Loop B", Some("Loop A"), None));
let lid = d.default_layer();
let names = [designcraft_doc::story::BASIC_PARAGRAPH, "Root", "Child", "Explicit", "Under explicit", "Loop A", "Loop B", "Missing"];
let text = names.join("\n");
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, &text, ParaFormat::default()).unwrap();
{
let s = d.story_mut(sid).unwrap();
for (p, name) in s.paras.iter_mut().zip(names) {
p.style = name.into();
p.para.keep_lines_together = Some(true);
}
s.insert_note(1, "Note", ParaFormat { style: "Missing".into(), ..Default::default() });
}
let back = load(&save_v1(&d)).unwrap();
let resolve = |f: &ParaFormat| back.styles.resolve_para(f).0;
let story = back.story(sid).unwrap();
for (p, name) in story.paras.iter().zip(names) {
let pp = resolve(p);
assert!(pp.keep_lines_together);
assert_eq!(pp.keep_all_lines, !name.contains("xplicit"), "{name}");
}
assert!(resolve(&story.notes[0].text.paras[0]).keep_all_lines, "footnote with a missing style");
assert!(back.styles.resolve_para_style("Loop A").0.keep_all_lines && back.styles.resolve_para_style("Loop B").0.keep_all_lines);
// Pinned where the chains start; explicit and inheriting styles are left as they were.
assert_eq!(back.styles.para("Root").unwrap().para.keep_all_lines, Some(true));
assert_eq!(back.styles.para("Child").unwrap().para.keep_all_lines, None);
assert_eq!(back.styles.para("Under explicit").unwrap().para.keep_all_lines, None);
// The current format keeps the new default: at start/end of paragraph.
let now = load(&save(&d).unwrap()).unwrap();
let p = &now.story(sid).unwrap().paras[0];
assert!(!now.styles.resolve_para(p).0.keep_all_lines);
assert!(!designcraft_doc::ParaProps::default().keep_all_lines);
}
/// Format 1 wrote paragraph rules, text frame options and numbered lists in full, so their
/// old defaults (black rules, auto-size from the top centre, lists continuing across stories)
/// load as saved. New documents take InDesign's.
#[test]
fn format_1_rules_frames_and_lists_load_as_saved() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let old_rule = designcraft_doc::Rule { on: true, color: designcraft_doc::color::swatch::BLACK.into(), ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "Ruled", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(old_rule.clone());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.auto_size_ref = 1;
d.settings.lists.push(designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: true });
let back = load(&save_v1(&d)).unwrap();
assert_eq!(back.story(sid).unwrap().paras[0].para.rule_above, Some(old_rule));
assert_eq!(back.item(fid).unwrap().text_frame().unwrap().options.auto_size_ref, 1);
assert!(back.settings.lists[0].continue_across_stories);
assert_eq!(designcraft_doc::Rule::default().color, designcraft_doc::TEXT_COLOR);
assert_eq!(designcraft_doc::TextFrameOptions::default().auto_size_ref, 4);
assert!(!designcraft_doc::NumberedList::default().continue_across_stories);
}
/// Format 1 set drop caps without Align Left Edge: those documents still do where nothing set
/// it. New documents align the drop cap's left edge.
#[test]
fn format_1_drop_caps_stay_unaligned() {
let mut d = Document::new(&NewDocument::default());
let st = d.styles_mut();
st.paragraph.push(para_style("Opener", None, None));
st.paragraph.push(designcraft_doc::ParagraphStyle {
para: designcraft_doc::ParaAttrs { drop_cap_align_left: Some(true), ..Default::default() },
..para_style("Aligned", None, None)
});
let lid = d.default_layer();
let (_, sid) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "One\nTwo\nThree", ParaFormat::default()).unwrap();
{
let s = d.story_mut(sid).unwrap();
for (p, name) in s.paras.iter_mut().zip(["Opener", "Aligned", "Missing"]) {
p.style = name.into();
p.para.drop_cap_lines = Some(2);
p.para.drop_cap_chars = Some(1);
}
}
let back = load(&save_v1(&d)).unwrap();
let aligned: Vec<bool> = back.story(sid).unwrap().paras.iter().map(|p| back.styles.resolve_para(p).0.drop_cap_align_left).collect();
assert_eq!(aligned, [false, true, false]);
let now = load(&save(&d).unwrap()).unwrap();
assert!(now.styles.resolve_para(&now.story(sid).unwrap().paras[0]).0.drop_cap_align_left);
}
#[test]
fn saves_hold_each_used_file_once() {
let mut d = Document::new(&NewDocument::default());

View File

@ -1,10 +1,9 @@
//! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy.
//!
//! 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.
//! 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).
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2};
use kurbo::{BezPath, Point, Vec2};
use serde::{Deserialize, Serialize};
use crate::path::{PathData, SubPath};
@ -64,9 +63,8 @@ impl CornerOptions {
}
}
/// 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]`.
/// 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]`.
pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath {
if opts.is_none() {
return path.to_bezpath();
@ -78,84 +76,50 @@ 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();
if !sp.closed || n < 2 {
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) {
sp.to_bezpath_into(out);
return;
}
// 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;
}
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);
let pts: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
let mut first = true;
for i in 0..n {
let seg = &trimmed[i];
if lines[i] {
out.line_to(seg.p3);
} else {
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),
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);
}
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;
} else {
out.line_to(a);
}
corner_shape(out, c.shape, p, a, b, ui, uo, s);
}
out.close_path();
}
@ -221,93 +185,6 @@ mod tests {
assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}");
}
/// InDesign draws a 28.35 pt radius on a 51.02 pt tall frame as 25.51 (half the shorter side),
/// as a circular arc (handles 0.5523 × the radius).
#[test]
fn radius_is_clamped_to_half_the_shorter_side() {
let r = rectangle(Rect::new(0.0, 0.0, 200.0, 51.02));
let bp = apply(&r, &CornerOptions::uniform(CornerShape::Rounded, 28.35));
let half = 51.02 / 2.0;
let mut arcs = 0;
let mut last = Point::ZERO;
for el in bp.elements() {
match *el {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => last = p,
kurbo::PathEl::CurveTo(c1, c2, p) => {
assert!(((p - last).x.abs() - half).abs() < 1e-9 && ((p - last).y.abs() - half).abs() < 1e-9, "{last:?} → {p:?}");
assert!(((c1 - last).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9);
assert!(((c2 - p).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9);
arcs += 1;
last = p;
}
_ => {}
}
}
assert_eq!(arcs, 4);
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));

View File

@ -47,22 +47,6 @@ 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.
@ -92,10 +76,5 @@ 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);
}
}

View File

@ -33,17 +33,10 @@ 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();
@ -140,8 +133,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 topic path → first serialized sort key; See / See also cross-references.
index_topics: BTreeMap<Vec<String>, String>,
/// Index topics (paths) and their See / See also cross-references.
index_topics: BTreeSet<Vec<String>>,
index_xrefs: Vec<(Vec<String>, &'static str, String)>,
}
@ -155,7 +148,7 @@ impl<'a> Ex<'a> {
fonts: BTreeMap::new(),
threads: HashMap::new(),
xref_sources: Vec::new(),
index_topics: BTreeMap::new(),
index_topics: BTreeSet::new(),
index_xrefs: Vec::new(),
};
ex.assign_swatch_ids();
@ -866,8 +859,7 @@ impl<'a> Ex<'a> {
}
(ps.para.clone(), ps.chars.clone())
};
let parent = (ps.name != designcraft_doc::NO_PARA_STYLE).then(|| ps.based_on.as_deref().unwrap_or(designcraft_doc::NO_PARA_STYLE));
self.para_attrs(&mut el, &mut props, &pa, parent);
self.para_attrs(&mut el, &mut props, &pa);
self.char_attrs(&mut el, &mut props, &ca);
pg.insert(&ps.name, with_props(el, props));
}
@ -1245,9 +1237,7 @@ impl<'a> Ex<'a> {
}
}
/// Write paragraph attributes `a`, set over the resolved paragraph style `parent` (`None` for the
/// root style).
fn para_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &ParaAttrs, parent: Option<&str>) {
fn para_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &ParaAttrs) {
if let Some(v) = &a.mojikumi {
props.push(p("Mojikumi", if v.starts_with("MojikumiTable/") { "object" } else { "enumeration" }, v));
}
@ -1295,31 +1285,8 @@ impl<'a> Ex<'a> {
None => el.set("NumberingContinue", "true"),
}
}
// Nested and GREP styles: property lists of records. The drop cap's character style is the
// nested style list's leading "through 1 Dropcap" entry (IDML has no attribute for it): a
// drop cap style set here (`[None]` included) restyles that entry, and a real one is
// inserted where there is none. Without a list of its own, the inherited list is written
// when that is what carries the drop cap style, since any list replaces the inherited one.
let real = |s: &str| !s.is_empty() && s != st::NO_CHAR_STYLE;
let cap = a.drop_cap_style.as_deref().filter(|s| !s.is_empty());
let mut list = a.nested_styles.clone();
if let (Some(cap), None) = (cap, &list) {
let inherited = parent.map(|n| self.d.styles.resolve_para_style(n).0);
let inherited_cap = inherited
.as_ref()
.is_some_and(|pp| real(&pp.drop_cap_style) || pp.nested_styles.first().is_some_and(|ns| ns.is_drop_cap() && real(&ns.style)));
if real(cap) || inherited_cap {
list = Some(inherited.map(|pp| pp.nested_styles).unwrap_or_default());
}
}
if let (Some(cap), Some(list)) = (cap, list.as_mut()) {
match list.first_mut() {
Some(ns) if ns.is_drop_cap() => ns.style = cap.to_string(),
_ if real(cap) => list.insert(0, designcraft_doc::NestedStyle::drop_cap(cap)),
_ => {}
}
}
if let Some(list) = &list {
// Nested and GREP styles: property lists of records.
if let Some(list) = &a.nested_styles {
let mut l = El::new("AllNestedStyles").attr("type", "list");
for ns in list {
let (ty, delim) = names::nested_until_out(&ns.until);
@ -1403,12 +1370,6 @@ impl<'a> Ex<'a> {
n!(space_after, "SpaceAfter");
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 = 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);
}
if let Some(g) = a.grid_align {
match g {
designcraft_doc::GridAlign::None => el.set("GridAlignment", "None"),
@ -1560,11 +1521,7 @@ impl<'a> Ex<'a> {
el.set(&format!("{k}Offset"), num(r.offset));
el.set(&format!("{k}LeftIndent"), num(r.left_indent));
el.set(&format!("{k}RightIndent"), num(r.right_indent));
props.push(if r.color == designcraft_doc::TEXT_COLOR {
p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR)
} else {
p(&format!("{k}Color"), "object", self.sw(&r.color))
});
props.push(p(&format!("{k}Color"), "object", self.sw(&r.color)));
}
fn stroke_attrs(&self, el: &mut El, s: &designcraft_doc::Stroke) {
@ -1672,7 +1629,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(pt.end.map_or(len, |e| e.min(len)))),
.attr("EndBracket", num(len)),
);
} else if let Content::Text(t) = &it.content {
let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None));
@ -1852,12 +1809,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.entry(vec![target.clone()]).or_default();
topics.insert(vec![target.clone()]);
}
fn build(ex: &mut Ex, topics: &BTreeMap<Vec<String>, String>, prefix: &[String]) -> Vec<El> {
fn build(ex: &mut Ex, topics: &BTreeSet<Vec<String>>, prefix: &[String]) -> Vec<El> {
let mut out = Vec::new();
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 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 (from, kind, target) in ex.index_xrefs.clone() {
if from == *t {
el.push(
@ -2112,9 +2069,7 @@ 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() {
// 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());
self.index_topics.insert(r.topics[..n].to_vec());
}
let (kind, limit) = match &r.range {
R::CurrentPage => (Some("CurrentPage"), None),
@ -2440,7 +2395,7 @@ impl<'a> Ex<'a> {
fn psr_el(&mut self, pf: &ParaFormat) -> El {
let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style));
let mut props = Vec::new();
self.para_attrs(&mut el, &mut props, &pf.para, Some(&pf.style));
self.para_attrs(&mut el, &mut props, &pf.para);
self.char_attrs(&mut el, &mut props, &pf.chars);
with_props(el, props)
}

View File

@ -206,32 +206,11 @@ 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 and per-level sort keys; topic cross-references.
index_topics: HashMap<String, IndexTopic>,
/// Index topic Self → topic path; topic See / See also cross-references.
index_topics: HashMap<String, Vec<String>>,
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])
@ -253,12 +232,9 @@ fn parse_point(s: Option<&str>) -> Option<Point> {
(v.len() >= 2).then(|| Point::new(v[0], v[1]))
}
/// InDesign's corner size where a file gives a corner shape without one.
const INDESIGN_CORNER_SIZE: f64 = 12.0;
/// IDML tint (percent; `-1` = the colour's own tint, 100 %) → 0..1.
/// IDML tint (percent, `-1` = default) → 0..1.
fn tint(v: Option<f64>) -> Option<f32> {
v.map(|t| if t < 0.0 || t.is_nan() { 1.0 } else { (t / 100.0).min(1.0) as f32 })
v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 })
}
impl<'r> Importer<'r> {
@ -477,7 +453,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, &IndexTopic::default());
self.index_topics_of(ix, &[]);
}
// Stories (ids first so frames can reference them).
for e in top.iter().filter(|e| e.local() == "Story") {
@ -791,10 +767,10 @@ impl<'r> Importer<'r> {
/// A swatch reference (Self id) → our swatch name; unnamed colours become value-named swatches.
/// 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).prop("ColumnRuleStrokeColor") {
o.column_rule_color = self.swatch_ref(&r);
fn frame_options(&mut self, e: &El) -> TextFrameOptions {
let mut o = text_frame_options(e);
if let Some(r) = e.get("ColumnRuleStrokeColor") {
o.column_rule_color = self.swatch_ref(r);
}
o
}
@ -855,7 +831,7 @@ impl<'r> Importer<'r> {
let name = e.get("Name").unwrap_or("").to_string();
if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) {
self.lists
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") == Some("true") });
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") != Some("false") });
}
}
let mut paras: Vec<(String, El)> = Vec::new();
@ -1003,7 +979,7 @@ impl<'r> Importer<'r> {
.then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)))
.flatten();
let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") {
e.find("TextFramePreference").map(|t| self.frame_options(&[t]))
e.find("TextFramePreference").map(|t| self.frame_options(t))
} else {
None
};
@ -1034,31 +1010,33 @@ 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: &IndexTopic) {
fn index_topics_of(&mut self, e: &El, prefix: &[String]) {
for t in e.find_all("Topic") {
let topic = prefix.child(t);
let mut path = prefix.to_vec();
path.push(t.get("Name").unwrap_or("").to_string());
if let Some(me) = t.get("Self") {
self.index_topics.insert(me.to_string(), topic.clone());
self.index_topics.insert(me.to_string(), path.clone());
}
self.index_topics_of(t, &topic);
self.index_topics_of(t, &path);
}
if !prefix.path.is_empty() {
if !prefix.is_empty() {
return;
}
let mut stack = vec![(e, IndexTopic::default())];
while let Some((el, prefix)) = stack.pop() {
let mut stack = vec![(e, Vec::<String>::new())];
while let Some((el, path)) = stack.pop() {
for t in el.find_all("Topic") {
let topic = prefix.child(t);
let mut p = path.clone();
p.push(t.get("Name").unwrap_or("").to_string());
for x in t.find_all("CrossReference") {
let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|t| t.path.join(": ")).unwrap_or_default();
let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|p| p.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: topic.path.clone(), sort: topic.sort.clone(), range });
self.index_xrefs.push(designcraft_doc::IndexRef { topics: p.clone(), sort: vec![], range });
}
stack.push((t, topic));
stack.push((t, p));
}
}
}
@ -1121,7 +1099,7 @@ impl<'r> Importer<'r> {
o.span_columns = e.boolean("EnableStraddling").unwrap_or(false);
o.rule.on = e.boolean("RuleOn").unwrap_or(true);
o.rule.weight = e.num("RuleLineWeight").unwrap_or(1.0);
o.rule.tint = tint(e.num("RuleTint")).unwrap_or(1.0);
o.rule.tint = (e.num("RuleTint").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
o.rule.left_indent = e.num("RuleLeftIndent").unwrap_or(0.0);
o.rule.width = e.num("RuleWidth").unwrap_or(72.0);
o.rule.offset = e.num("RuleOffset").unwrap_or(0.0);
@ -1205,7 +1183,7 @@ impl<'r> Importer<'r> {
// An explicit Text Color resets an inherited swatch; absence still inherits.
// Resolve the sentinel before swatch_ref, which maps unknown names to [None].
let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) });
let tint = tint(e.num(&format!("{k}Tint")));
let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32);
if k == "Underline" {
a.underline_weight = num("Weight").map(Some);
a.underline_offset = num("Offset").map(Some);
@ -1339,9 +1317,6 @@ impl<'r> Importer<'r> {
}
})
.collect();
// IDML has no drop cap style attribute: the drop cap's character style is the list's
// leading "through 1 Dropcap" nested style.
a.drop_cap_style = v.first().filter(|ns| ns.is_drop_cap()).map(|ns| ns.style.clone());
a.nested_styles = Some(v);
}
if let Some(l) = e.prop_el("AllNestedLineStyles") {
@ -1370,12 +1345,6 @@ impl<'r> Importer<'r> {
a.space_after = e.num("SpaceAfter");
a.drop_cap_lines = u("DropCapLines");
a.drop_cap_chars = u("DropCapCharacters");
// DropcapDetail bits: 1 = Align Left Edge, 2 = Scale for Descenders.
if let Some(v) = e.num("DropcapDetail").filter(|v| v.is_finite() && *v >= 0.0) {
let bits = v as u32;
a.drop_cap_align_left = Some(bits & 1 != 0);
a.drop_cap_scale_descenders = Some(bits & 2 != 0);
}
if let Some(g) = e.prop("GridAlignment") {
a.grid_align = Some(if g == "None" {
GridAlign::None
@ -1487,9 +1456,7 @@ impl<'r> Importer<'r> {
}
if let Some(c) = e.prop(&format!("{k}Color")) {
let c = c.trim();
if c == designcraft_doc::TEXT_COLOR {
r.color = c.to_string();
} else if c.contains('/') {
if c.contains('/') {
r.color = self.swatch_ref(c);
}
}
@ -1829,14 +1796,14 @@ impl<'r> Importer<'r> {
}
"PageReference" => {
use designcraft_doc::index::IndexRange as R;
let topic = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default();
let topics = 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: topic.path, sort: topic.sort, range });
b.index_refs.push(designcraft_doc::IndexRef { topics, sort: vec![], range });
b.push(&designcraft_doc::INDEX_MARK.to_string(), cf);
}
"CrossReferenceSource" => {
@ -2020,33 +1987,25 @@ impl<'r> Importer<'r> {
})
}
/// An item's object style chain: its applied style, the styles that one is based on, then
/// `[None]` (cycle-safe).
fn object_style_chain(&self, e: &El) -> Vec<&El> {
let mut chain: Vec<&El> = Vec::new();
let mut os = e.get("AppliedObjectStyle").map(str::to_string);
while let Some(id) = os {
let Some(s) = self.object_els.get(&id) else { break };
if chain.len() >= 16 || chain.iter().any(|c| std::ptr::eq(*c, s)) {
break;
}
chain.push(s);
os = s.prop("BasedOn").map(|b| {
let b = b.trim();
if b.starts_with("ObjectStyle/") { b.to_string() } else { format!("ObjectStyle/{}", names::escape_id(b)) }
});
}
if let Some(none) = self.object_els.get("ObjectStyle/$ID/[None]")
&& !chain.iter().any(|c| std::ptr::eq(*c, none))
{
chain.push(none);
}
chain
}
/// Resolve an item attribute, falling back to its object style chain.
fn attr_or_style(&self, e: &El, k: &str) -> Option<String> {
e.get(k).or_else(|| self.object_style_chain(e).into_iter().find_map(|s| s.get(k))).map(str::to_string)
if let Some(v) = e.get(k) {
return Some(v.to_string());
}
let mut os = e.get("AppliedObjectStyle").map(str::to_string);
let mut depth = 0;
while let Some(id) = os {
depth += 1;
if depth > 16 {
break;
}
let Some(s) = self.object_els.get(&id) else { break };
if let Some(v) = s.get(k) {
return Some(v.to_string());
}
os = s.prop("BasedOn").map(|b| if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) });
}
None
}
fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke {
@ -2152,21 +2111,19 @@ impl<'r> Importer<'r> {
it.stroke.weight = 1.0;
}
}
// Corners: each corner's shape and radius come from the item, then its object style chain;
// at each level a per-corner attribute wins over the legacy all-corners one.
if tag != "Group" {
let levels: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).collect();
let corner =
|n: &str, what: &str| levels.iter().find_map(|s| s.get(&format!("{n}Corner{what}")).or_else(|| s.get(&format!("Corner{what}"))));
let mut corners = CornerOptions::default();
for (i, n) in crate::export::corner_names(&it.path).iter().enumerate() {
let shape = corner(n, "Option").map(names::corner_in).unwrap_or_default();
let size = corner(n, "Radius").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(INDESIGN_CORNER_SIZE);
corners.corners[i] = Corner { shape, size };
}
if !corners.is_none() {
it.corners = corners;
}
// Corners.
let cn = crate::export::corner_names(&it.path);
// The legacy all-corners attributes apply only when no per-corner attribute is present.
let per_corner = cn.iter().any(|n| e.get(&format!("{n}CornerOption")).is_some());
let legacy = if per_corner { (None, None) } else { (e.get("CornerOption"), e.num("CornerRadius")) };
let mut corners = CornerOptions::default();
for (i, n) in cn.iter().enumerate() {
let shape = e.get(&format!("{n}CornerOption")).or(legacy.0).map(names::corner_in).unwrap_or_default();
let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(0.0);
corners.corners[i] = Corner { shape, size };
}
if !corners.is_none() {
it.corners = corners;
}
// Transparency.
if let Some(t) = e.find("TransparencySetting") {
@ -2224,11 +2181,6 @@ 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("")),
}),
@ -2251,13 +2203,7 @@ impl<'r> Importer<'r> {
sid
}
};
// Options the frame doesn't set come from its object style chain.
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).prop("ColumnRuleStrokeColor") {
options.column_rule_color = self.swatch_ref(&r);
}
let mut options = e.find("TextFramePreference").map(|t| self.frame_options(t)).unwrap_or_default();
if let Some(g) = e.find("BaselineFrameGridOption")
&& g.get("UseCustomBaselineFrameGrid") == Some("true")
{
@ -2433,35 +2379,12 @@ impl<'r> Importer<'r> {
self.spreads.iter().chain(self.parents.iter()).find_map(|s| find(&s.items, id))
}
/// A nested style list replaces the one a style or paragraph inherits, and with it the drop
/// cap style (the list's leading "through 1 Dropcap" entry, read into the drop cap style). A
/// list of its own without that entry clears an inherited drop cap style.
fn clear_inherited_drop_cap_styles(&mut self) {
let clears = |a: &designcraft_doc::ParaAttrs| a.drop_cap_style.is_none() && a.nested_styles.is_some();
let cleared: Vec<String> = self
.styles
.paragraph
.iter()
.filter(|s| clears(&s.para) && inherits_drop_cap_style(&self.styles, s.based_on.as_deref()))
.map(|s| s.name.clone())
.collect();
for name in cleared {
if let Some(s) = self.styles.para_mut(&name) {
s.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into());
}
}
for story in self.stories.values_mut() {
clear_story_drop_cap_styles(&self.styles, story, 0);
}
}
fn finish(mut self, root: &El) -> Result<Document> {
let title = root.get("Name").map(|n| n.trim_end_matches(".indd").to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Untitled".into());
if self.spreads.is_empty() {
return Err(IdmlError::Invalid("document has no spreads".into()));
}
self.settings.lists = std::mem::take(&mut self.lists);
self.clear_inherited_drop_cap_styles();
// Make sure built-in paragraph styles exist and are first.
let d = Document {
title,
@ -2578,26 +2501,24 @@ fn ace_char(v: &str) -> Option<char> {
}
fn margins_of(m: &El) -> (Margins, Columns) {
let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d);
let g = |k: &str, d: f64| m.num(k).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).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
Columns { count: g("ColumnCount", 1.0).max(1.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
)
}
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
let e = Prefs(prefs);
fn text_frame_options(e: &El) -> TextFrameOptions {
let mut o = TextFrameOptions::default();
if let Some(v) = e.num("TextColumnCount") {
o.columns = v.clamp(1.0, 216.0) as u32;
o.columns = v.max(1.0) as u32;
}
if let Some(v) = e.num("TextColumnGutter") {
o.gutter = v;
}
o.columns_kind = if e.prop("UseFixedColumnWidth").as_deref().map(str::trim) == Some("true") {
o.columns_kind = if e.get("UseFixedColumnWidth") == Some("true") {
ColumnsKind::FixedWidth
} else if e.prop("UseFlexibleColumnWidth").as_deref().map(str::trim) == Some("true") {
} else if e.get("UseFlexibleColumnWidth") == Some("true") {
ColumnsKind::FlexibleWidth
} else {
ColumnsKind::FixedNumber
@ -2607,33 +2528,31 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
{
o.column_width = v;
}
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.balance_columns = e.get("VerticalBalanceColumns") == Some("true");
if let Some(inset) = inset_spacing(e) {
o.inset = inset;
}
if let Some(v) = e.prop("VerticalJustification") {
o.vertical_justification = names::vj_in(v.trim());
if let Some(v) = e.get("VerticalJustification") {
o.vertical_justification = names::vj_in(v);
}
if let Some(v) = e.num("VerticalThreshold") {
o.vj_paragraph_spacing_limit = v;
}
if let Some(v) = e.prop("FirstBaselineOffset") {
o.first_baseline = names::first_baseline_in(v.trim());
if let Some(v) = e.get("FirstBaselineOffset") {
o.first_baseline = names::first_baseline_in(v);
}
if let Some(v) = e.num("MinimumFirstBaselineOffset") {
o.first_baseline_min = 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());
o.ignore_wrap = e.get("IgnoreWrap") == Some("true");
if let Some(v) = e.get("AutoSizingType") {
o.auto_size = names::auto_size_in(v);
}
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;
if let Some(v) = e.get("AutoSizingReferencePoint") {
o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8;
}
// Column rules (the colour is a swatch reference: see `Importer::frame_options`).
o.column_rule = e.prop("ColumnRuleOverride").as_deref().map(str::trim) == Some("true");
o.column_rule = e.get("ColumnRuleOverride") == 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);
@ -2653,60 +2572,6 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
o
}
/// Deepest table-in-cell or note nesting the drop cap pass walks into.
const MAX_STORY_NESTING: usize = 32;
/// [`Importer::clear_inherited_drop_cap_styles`] for one story's paragraphs and those of the table
/// cells, footnotes and endnotes in it.
fn clear_story_drop_cap_styles(styles: &Styles, story: &mut Story, depth: usize) {
for p in &mut story.paras {
if p.para.drop_cap_style.is_none() && p.para.nested_styles.is_some() && inherits_drop_cap_style(styles, Some(&p.style)) {
p.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into());
}
}
if depth >= MAX_STORY_NESTING {
return;
}
for table in story.tables.values_mut() {
for cell in &mut Arc::make_mut(table).cells {
clear_story_drop_cap_styles(styles, &mut cell.text, depth + 1);
}
}
for note in story.notes.iter_mut().chain(story.endnotes.iter_mut()) {
clear_story_drop_cap_styles(styles, &mut Arc::make_mut(note).text, depth + 1);
}
}
/// Does paragraph style `name` (and its based-on chain) set a drop cap character style? A style
/// with a nested style list of its own and no drop cap style has none.
fn inherits_drop_cap_style<'a>(styles: &'a Styles, mut name: Option<&'a str>) -> bool {
let mut seen = HashSet::new();
while let Some(n) = name {
let Some(s) = styles.para(n).filter(|_| seen.insert(n)) else { break };
if let Some(c) = s.para.drop_cap_style.as_deref() {
return !c.is_empty() && c != st::NO_CHAR_STYLE;
}
if s.para.nested_styles.is_some() {
return false;
}
name = s.based_on.as_deref();
}
false
}
/// Property lookup over preference levels: the nearest level wins, attributes before children.
struct Prefs<'a>(&'a [&'a El]);
impl Prefs<'_> {
fn prop(&self, k: &str) -> Option<String> {
self.0.iter().find_map(|e| e.prop(k))
}
fn num(&self, k: &str) -> Option<f64> {
// 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())
}
}
/// A scalar applies to every edge; a list is top, left, bottom, right. Keep the
/// existing property-element precedence over the legacy scalar attribute, even
/// when the property is malformed. Finite negative insets remain supported.
@ -2763,244 +2628,3 @@ 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 "));
}
}

View File

@ -424,11 +424,10 @@ pub fn first_baseline_out(f: FirstBaseline) -> &'static str {
FirstBaseline::Leading => "LeadingOffset",
FirstBaseline::XHeight => "XHeight",
FirstBaseline::Fixed => "FixedHeight",
FirstBaseline::EmboxHeight => "EmboxHeight",
}
}
pub fn first_baseline_in(s: &str) -> FirstBaseline {
[FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed, FirstBaseline::EmboxHeight]
[FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed]
.into_iter()
.find(|m| first_baseline_out(*m) == s)
.unwrap_or(FirstBaseline::Ascent)
@ -448,7 +447,6 @@ pub fn auto_size_in(s: &str) -> AutoSize {
.find(|m| auto_size_out(*m) == s)
.unwrap_or(AutoSize::Off)
}
/// Auto-size reference points in our 0..9 order (row-major, top-left first).
pub const REF_POINTS: [&str; 9] = [
"TopLeftPoint",
@ -637,7 +635,6 @@ pub fn nested_until_out(u: &designcraft_doc::NestedUntil) -> (&'static str, Stri
N::ForcedLineBreak => e("ForcedLineBreak"),
N::EmSpace => e("EmSpaces"),
N::EnSpace => e("EnSpaces"),
N::Dropcap => e("Dropcap"),
N::Chars(c) => ("string", c.clone()),
}
}
@ -656,7 +653,6 @@ pub fn nested_until_in(ty: &str, v: &str) -> designcraft_doc::NestedUntil {
"ForcedLineBreak" => N::ForcedLineBreak,
"EmSpaces" => N::EmSpace,
"EnSpaces" => N::EnSpace,
"Dropcap" => N::Dropcap,
_ => N::Words,
}
}

View File

@ -9,7 +9,6 @@ use super::*;
mod assets;
mod decorations;
mod index_sort;
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
use zip::write::SimpleFileOptions;
@ -32,20 +31,6 @@ fn zip_files_plain() -> Vec<u8> {
w.finish().unwrap().into_inner()
}
/// The text of every part of the package `bytes` whose name starts with `prefix`.
fn zip_text(bytes: &[u8], prefix: &str) -> String {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = String::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
if f.name().starts_with(prefix) {
f.read_to_string(&mut out).unwrap();
}
}
out
}
/// A hand-written, minimal IDML document: one facing-pages spread with a right page, a
/// threaded story across two frames, a tint and a grouped paragraph style.
const DESIGNMAP: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@ -380,17 +365,6 @@ fn imports_scalar_inset_properties_like_lists_and_attributes() {
assert_eq!(frame_inset(&round_trip), [9.0; 4]);
}
#[test]
fn em_box_first_baseline_offset_round_trips() {
use designcraft_doc::FirstBaseline;
let first = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.first_baseline;
let d = inset_fixture(r#"<TextFramePreference FirstBaselineOffset="EmboxHeight"/>"#, "");
assert_eq!(first(&d), FirstBaseline::EmboxHeight);
assert_eq!(first(&import_idml(&export_idml(&d)).unwrap()), FirstBaseline::EmboxHeight);
let unknown = inset_fixture(r#"<TextFramePreference FirstBaselineOffset="SomethingElse"/>"#, "");
assert_eq!(first(&unknown), FirstBaseline::Ascent);
}
#[test]
fn inset_property_keeps_precedence_over_attribute() {
for (property, expected) in [
@ -1480,692 +1454,3 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() {
check(&d);
check(&import_idml(&export_idml(&d)).unwrap());
}
fn fixture_with_styles(styles: &str) -> Vec<u8> {
zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", styles),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", STORY),
])
}
/// Paragraph styles `Body` (based on the root) and `Loose` (no BasedOn), both with `attrs`.
fn keep_styles(attrs: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" {attrs}>
<Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Loose" Name="Loose" {attrs}/>
</RootParagraphStyleGroup>
</idPkg:Styles>"#
)
}
/// Keep Lines Together whose mode no style sets is InDesign's default: at start/end of paragraph.
/// An explicit mode still wins.
#[test]
fn keep_lines_mode_defaults_to_start_and_end() {
let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#;
for (attrs, all) in [(keep.to_string(), false), (format!(r#"{keep} KeepAllLinesTogether="true""#), true)] {
let d = import_idml_with(&fixture_with_styles(&keep_styles(&attrs)), &|_| None).unwrap();
for name in ["Body", "Loose"] {
let (pp, _) = d.styles.resolve_para_style(name);
assert_eq!((pp.keep_lines_together, pp.keep_all_lines, pp.keep_first, pp.keep_last), (true, all, 2, 2), "{name}: {attrs}");
}
}
}
type Resolved = (designcraft_doc::ParaProps, designcraft_doc::CharProps);
/// (IDML attribute, InDesign's default as IDML writes it, another value, the resolved field,
/// its value for InDesign's default, its value for the other value).
type AttrRow = (&'static str, &'static str, &'static str, fn(&Resolved) -> String, String, String);
macro_rules! attr_rows {
($($attr:literal: $default:literal => $dv:expr, $other:literal => $ov:expr, |$r:ident| $field:expr;)*) => {
vec![$(($attr, $default, $other, (|$r: &Resolved| format!("{:?}", $field)) as fn(&Resolved) -> String, format!("{:?}", $dv), format!("{:?}", $ov)),)*]
};
}
/// InDesign's defaults for a new document's [No Paragraph Style], for every paragraph and
/// character attribute the importer reads (except the font family: see below).
fn indesign_text_defaults() -> Vec<AttrRow> {
use designcraft_doc::{Align, Capitalization, Composer, Digits, GridAlign, Leading, ListType, Position, StartParagraph, TextDirection};
attr_rows! {
"Justification": "LeftAlign" => Align::Left, "CenterAlign" => Align::Center, |r| r.0.align;
"ParagraphDirection": "LeftToRightDirection" => TextDirection::LeftToRight, "RightToLeftDirection" => TextDirection::RightToLeft, |r| r.0.direction;
"LeftIndent": "0" => 0.0, "6" => 6.0, |r| r.0.left_indent;
"RightIndent": "0" => 0.0, "6" => 6.0, |r| r.0.right_indent;
"FirstLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.first_line_indent;
"LastLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.last_line_indent;
"SpaceBefore": "0" => 0.0, "6" => 6.0, |r| r.0.space_before;
"SpaceAfter": "0" => 0.0, "6" => 6.0, |r| r.0.space_after;
"DropCapLines": "0" => 0, "3" => 3, |r| r.0.drop_cap_lines;
"DropCapCharacters": "0" => 0, "1" => 1, |r| r.0.drop_cap_chars;
"DropcapDetail": "1" => (true, false), "2" => (false, true), |r| (r.0.drop_cap_align_left, r.0.drop_cap_scale_descenders);
"GridAlignment": "None" => GridAlign::None, "AlignToBaseline" => GridAlign::AllLines, |r| r.0.grid_align;
"Composer": "HL Composer" => Composer::Paragraph, "HL Single" => Composer::SingleLine, |r| r.0.composer;
"Hyphenation": "true" => true, "false" => false, |r| r.0.hyphenate;
"HyphenateWordsLongerThan": "5" => 5, "7" => 7, |r| r.0.hyph_min_word;
"HyphenateAfterFirst": "2" => 2, "3" => 3, |r| r.0.hyph_after_first;
"HyphenateBeforeLast": "2" => 2, "3" => 3, |r| r.0.hyph_before_last;
"HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit;
"HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone;
"HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized;
"HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word;
"HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column;
"HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight;
"MinimumWordSpacing": "80" => 0.8, "70" => 0.7, |r| r.0.word_space_min;
"DesiredWordSpacing": "100" => 1.0, "90" => 0.9, |r| r.0.word_space_desired;
"MaximumWordSpacing": "133" => 1.33, "150" => 1.5, |r| r.0.word_space_max;
"MinimumLetterSpacing": "0" => 0.0, "-5" => -0.05, |r| r.0.letter_space_min;
"DesiredLetterSpacing": "0" => 0.0, "1" => 0.01, |r| r.0.letter_space_desired;
"MaximumLetterSpacing": "0" => 0.0, "5" => 0.05, |r| r.0.letter_space_max;
"MinimumGlyphScaling": "100" => 1.0, "97" => 0.97, |r| r.0.glyph_scale_min;
"DesiredGlyphScaling": "100" => 1.0, "101" => 1.01, |r| r.0.glyph_scale_desired;
"MaximumGlyphScaling": "100" => 1.0, "103" => 1.03, |r| r.0.glyph_scale_max;
"AutoLeading": "120" => 1.2, "100" => 1.0, |r| r.0.auto_leading;
"SingleWordJustification": "FullyJustified" => Align::FullyJustified, "LeftAlign" => Align::Left, |r| r.0.single_word_justify;
"KeepWithNext": "0" => 0, "1" => 1, |r| r.0.keep_with_next;
"KeepLinesTogether": "false" => false, "true" => true, |r| r.0.keep_lines_together;
"KeepAllLinesTogether": "false" => false, "true" => true, |r| r.0.keep_all_lines;
"KeepFirstLines": "2" => 2, "3" => 3, |r| r.0.keep_first;
"KeepLastLines": "2" => 2, "3" => 3, |r| r.0.keep_last;
"StartParagraph": "Anywhere" => StartParagraph::Anywhere, "NextColumn" => StartParagraph::NextColumn, |r| r.0.start_paragraph;
"BulletsAndNumberingListType": "NoList" => ListType::None, "BulletList" => ListType::Bullets, |r| r.0.list_type;
"BalanceRaggedLines": "NoBalancing" => false, "FullyBalanced" => true, |r| r.0.balance_ragged;
"Kashidas": "DefaultKashidas" => true, "KashidasOff" => false, |r| r.0.kashidas;
"RuleAbove": "false" => false, "true" => true, |r| r.0.rule_above.on;
"RuleAboveColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_above.color;
"RuleAboveTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_above.tint;
"RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on;
"RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color;
"RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint;
"ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on;
"ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color;
"ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint;
"ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on;
"ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color;
"ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint;
"FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style;
"PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size;
"Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading;
"KerningMethod": "$ID/Metrics" => designcraft_doc::Kerning::Metrics, "$ID/Optical" => designcraft_doc::Kerning::Optical, |r| r.1.kerning;
"Tracking": "0" => 0.0, "20" => 20.0, |r| r.1.tracking;
"HorizontalScale": "100" => 1.0, "90" => 0.9, |r| r.1.h_scale;
"VerticalScale": "100" => 1.0, "90" => 0.9, |r| r.1.v_scale;
"BaselineShift": "0" => 0.0, "2" => 2.0, |r| r.1.baseline_shift;
"Skew": "0" => 0.0, "10" => 10.0, |r| r.1.skew;
"FillColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.1.fill;
"FillTint": "-1" => 1.0, "50" => 0.5, |r| r.1.fill_tint;
"StrokeColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.1.stroke;
"StrokeTint": "-1" => 1.0, "50" => 0.5, |r| r.1.stroke_tint;
"StrokeWeight": "1" => 1.0, "2" => 2.0, |r| r.1.stroke_weight;
"Capitalization": "Normal" => Capitalization::Normal, "AllCaps" => Capitalization::AllCaps, |r| r.1.capitalization;
"Position": "Normal" => Position::Normal, "Superscript" => Position::Superscript, |r| r.1.position;
"Underline": "false" => false, "true" => true, |r| r.1.underline;
"StrikeThru": "false" => false, "true" => true, |r| r.1.strikethrough;
"UnderlineTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.underline_tint;
"StrikeThruTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.strikethrough_tint;
"Ligatures": "true" => true, "false" => false, |r| r.1.ligatures;
"NoBreak": "false" => false, "true" => true, |r| r.1.no_break;
"OTFContextualAlternate": "true" => true, "false" => false, |r| designcraft_doc::otf::is_on(&r.1.otf_features, "calt");
"DigitsType": "DefaultDigits" => Digits::Default, "ArabicDigits" => Digits::Arabic, |r| r.1.digits;
"AppliedLanguage": "$ID/English: USA" => "English: USA", "$ID/German: 2006 Reform" => "German: 2006 Reform", |r| r.1.language;
}
}
/// [No paragraph style] with `root` attributes and `Body`, based on it, with `body` attributes.
fn text_styles(root: &str, body: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" {root}/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" {body}>
<Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
</RootParagraphStyleGroup>
</idPkg:Styles>"#
)
}
fn resolve_body(root: &str, body: &str) -> Resolved {
let d = import_idml_with(&fixture_with_styles(&text_styles(root, body)), &|_| None).unwrap();
d.styles.resolve_para_style("Body")
}
/// Text attributes an IDML file leaves unset are InDesign's defaults; written ones win, and a
/// file that writes every default (as InDesign's own exports do) imports the same.
#[test]
fn absent_text_attributes_take_indesign_defaults() {
let rows = indesign_text_defaults();
let all = |pick: fn(&AttrRow) -> &str| rows.iter().map(|r| format!(r#"{}="{}""#, r.0, pick(r))).collect::<Vec<_>>().join(" ");
let (defaults, others) = (all(|r| r.1), all(|r| r.2));
let omitted = resolve_body("", "");
let written = resolve_body(&defaults, "");
for (attr, _, _, field, want, _) in &rows {
assert_eq!(field(&omitted), *want, "{attr} omitted");
assert_eq!(field(&written), *want, "{attr} written as InDesign's default");
}
assert_eq!(omitted, written);
for (root, body) in [(others.as_str(), ""), ("", others.as_str())] {
let r = resolve_body(root, body);
for (attr, _, _, field, _, want) in &rows {
assert_eq!(field(&r), *want, "{attr} written on the {}", if root.is_empty() { "style" } else { "root" });
}
}
// Product difference: InDesign's default face isn't available, so unset fonts are DesignCraft's.
assert_eq!(omitted.1.font_family, designcraft_doc::CharProps::default().font_family);
}
/// A tint of -1 is the colour's own tint (100 %): written on a style, it replaces its parent's.
/// Tints are never negative or above 100 %.
#[test]
fn minus_one_tint_is_the_colours_own() {
let tints = |v: &str| {
["UnderlineTint", "StrikeThruTint", "FillTint", "StrokeTint", "ParagraphShadingTint", "ParagraphBorderTint", "RuleAboveTint", "RuleBelowTint"]
.map(|k| format!(r#"{k}="{v}""#))
.join(" ")
+ r#" RuleAbove="true" RuleBelow="true""#
};
for (body, want) in [("-1", 1.0), ("150", 1.0)] {
let r = resolve_body(&tints("50"), &tints(body));
let got = [
r.1.underline_tint,
r.1.strikethrough_tint,
r.1.fill_tint,
r.1.stroke_tint,
r.0.shading_tint,
r.0.border_tint,
r.0.rule_above.tint,
r.0.rule_below.tint,
];
assert_eq!(got, [want; 8], "{body}");
}
// Footnote rules, table fills and strokes, cell fills.
let prefs = PREFS.replace("</idPkg:Preferences>", r#"<FootnoteOption RuleTint="-1"/></idPkg:Preferences>"#);
let story = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartRowFillTint="-1" EndRowFillTint="-1" TopBorderStrokeTint="-1">
<Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0" FillColor="Color/Black" FillTint="-1" TopEdgeStrokeTint="-1"/></Table></CharacterStyleRange></ParagraphStyleRange></Story>
</idPkg:Story>"#;
let d = import_idml_with(
&zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", story),
]),
&|_| None,
)
.unwrap();
assert_eq!(d.footnote_options.rule.tint, 1.0);
let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
let alt = t.options.alt_rows.as_ref().unwrap();
assert_eq!((alt.first_tint, alt.next_tint, t.options.border.tint), (1.0, 1.0, 1.0));
let c = t.cell(0, 0).unwrap();
assert_eq!((c.fill_tint, c.strokes[0].tint), (1.0, 1.0));
}
/// Rules in Text Color take the colour of the paragraph's text; they round-trip as Text Color.
#[test]
fn text_color_rules_round_trip() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let rule = designcraft_doc::Rule { on: true, ..Default::default() };
assert_eq!(rule.color, designcraft_doc::TEXT_COLOR);
let para = designcraft_doc::ParaAttrs {
rule_above: Some(rule.clone()),
rule_below: Some(designcraft_doc::Rule { color: "[Black]".into(), ..rule }),
kashidas: Some(false),
..Default::default()
};
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Ruled", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().paras[0].para = para.clone();
let bytes = export_idml(&d);
let story = zip_text(&bytes, "Stories/");
assert!(story.contains(r#"<RuleAboveColor type="string">Text Color</RuleAboveColor>"#), "{story}");
assert!(story.contains(r#"Kashidas="KashidasOff""#), "{story}");
let back = import_idml(&bytes).unwrap();
let p = &back.stories.values().find(|s| s.text == "Ruled").unwrap().paras[0];
assert_eq!((p.para.rule_above.as_ref(), p.para.rule_below.as_ref()), (para.rule_above.as_ref(), para.rule_below.as_ref()));
assert_eq!(p.para.kashidas, Some(false));
}
/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways.
#[test]
fn basic_paragraph_is_normal_paragraph_style() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
let basic = d.styles.para(st::BASIC_PARAGRAPH).unwrap();
assert_eq!(basic.based_on.as_deref(), Some(designcraft_doc::NO_PARA_STYLE));
assert_eq!(d.styles.para("Text/Body").unwrap().based_on.as_deref(), Some(st::BASIC_PARAGRAPH));
let s = d.stories.values().find(|s| s.text.starts_with("Hello")).unwrap();
assert_eq!(s.paras[1].style, st::BASIC_PARAGRAPH);
assert!(d.styles.paragraph.iter().all(|p| !p.name.contains("NormalParagraphStyle")));
let bytes = export_idml(&d);
let styles = zip_text(&bytes, "Resources/Styles.xml");
assert!(styles.contains(r#"Self="ParagraphStyle/$ID/NormalParagraphStyle" Name="$ID/NormalParagraphStyle""#), "{styles}");
assert!(zip_text(&bytes, "Stories/").contains(r#"AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle""#));
let back = import_idml(&bytes).unwrap();
assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1);
}
/// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults
/// for a new document.
#[test]
fn absent_item_and_document_attributes_take_indesign_defaults() {
use designcraft_doc::{Cap, FirstBaseline, Join, StrokeAlign, VerticalJustification};
use designcraft_geom::corners::CornerShape;
let path = |x: f64| {
let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties>"#
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
<NumberingList Self="NumberingList/Steps" Name="Steps"/>
<RootTableStyleGroup Self="rts"><TableStyle Self="TableStyle/Banded" Name="Banded" StartRowFillCount="1"/></RootTableStyleGroup>
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartColumnFillCount="2"><Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0"/></Table></CharacterStyleRange></ParagraphStyleRange></Story>
<Spread Self="sp1"><Page Self="p1"/>
<Rectangle Self="plain">{}</Rectangle>
<Rectangle Self="round" TopLeftCornerOption="RoundedCorner" TopRightCornerOption="RoundedCorner" BottomLeftCornerOption="RoundedCorner" BottomRightCornerOption="RoundedCorner">{}</Rectangle>
<Rectangle Self="legacy" CornerOption="RoundedCorner">{}</Rectangle>
<Rectangle Self="sized" TopLeftCornerOption="RoundedCorner" TopLeftCornerRadius="5">{}</Rectangle>
<TextFrame Self="tf" ParentStory="s1">{}</TextFrame>
</Spread>
</Document>"#,
path(0.0),
path(100.0),
path(200.0),
path(300.0),
path(400.0)
);
let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap();
// Document Setup, margins and columns, grids, Advanced Type, transparency, black.
let s = &d.settings;
assert_eq!((s.page_width, s.page_height, s.facing_pages, s.intent), (612.0, 792.0, true, designcraft_doc::Intent::Print));
assert_eq!((s.bleed, s.slug), ([0.0; 4], [0.0; 4]));
let page = &d.spreads[0].pages[0];
assert_eq!((page.margins.top, page.margins.bottom, page.margins.inside, page.margins.outside), (36.0, 36.0, 36.0, 36.0));
assert_eq!((page.columns.count, page.columns.gutter), (1, 12.0));
assert_eq!((s.baseline_grid.start, s.baseline_grid.increment, s.baseline_grid.view_threshold), (36.0, 12.0, 0.75));
assert_eq!((s.grid.horizontal, s.grid.vertical, s.grid.subdivisions, s.grid.in_back), (72.0, 72.0, 8, true));
let a = &s.advanced_type;
assert_eq!((a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position), (58.3, 33.3, 58.3, 33.3));
assert_eq!((s.blend_space, s.overprint_black, s.keyboard_increment), (designcraft_doc::BlendSpace::Cmyk, true, 1.0));
// Fill, stroke, transparency, wrap and corners of a frame with no attributes.
let item = |n: usize| d.spreads[0].items[n].clone();
let plain = item(0);
assert!(plain.fill.is_none() && plain.stroke.is_none());
let st = &plain.stroke;
assert_eq!((st.weight, st.miter_limit, st.cap, st.join, st.align), (1.0, 4.0, Cap::Butt, Join::Miter, StrokeAlign::Center));
assert_eq!((plain.opacity, plain.blend, plain.wrap.mode), (1.0, designcraft_color::BlendMode::Normal, designcraft_doc::WrapMode::None));
assert!(plain.corners.is_none());
// A corner shape without a size: 12 pt.
for n in [1, 2] {
assert_eq!(item(n).corners.corners.map(|c| (c.shape, c.size)), [(CornerShape::Rounded, 12.0); 4], "item {n}");
}
assert_eq!(item(3).corners.corners[0].size, 5.0);
// Text Frame Options.
let tf = item(4);
let o = &tf.text_frame().unwrap().options;
assert_eq!((o.columns, o.gutter, o.inset, o.balance_columns, o.ignore_wrap), (1, 12.0, [0.0; 4], false, false));
assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Top, FirstBaseline::Ascent, 0.0));
assert_eq!((o.auto_size, o.auto_size_ref), (designcraft_doc::AutoSize::Off, 4), "auto-size from the centre");
// Numbered lists don't continue across stories.
assert_eq!(d.settings.lists, vec![designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: false }]);
// Table and cell options.
let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!((t.options.space_before, t.options.space_after, t.options.repeat_header), (4.0, -4.0, true));
assert_eq!((t.options.border.weight, t.options.border.color.as_str()), (1.0, "[Black]"));
assert_eq!((t.rows[0].mode, t.rows[0].height), (designcraft_doc::RowHeightMode::AtLeast, 3.0));
// Alternating fills: the first rows or columns Black 20 %, the next None.
let fills = |a: &designcraft_doc::AltFills| (a.first_color.clone(), a.first_tint, a.next_color.clone(), a.next_tint, a.next);
let want = ("[Black]".to_string(), 0.2f32, "[None]".to_string(), 1.0f32, 1);
assert_eq!(fills(t.options.alt_rows.as_ref().unwrap()), want);
let cols = t.options.alt_cols.as_ref().unwrap();
assert_eq!((fills(cols), cols.first), (want.clone(), 2));
let banded = d.styles.table.iter().find(|s| s.name == "Banded").unwrap();
let mut bt = designcraft_doc::Table::new(0, 1, 1, 0, 0, 100.0);
d.styles.resolve_table_style(&banded.name).apply_to(&mut bt, &d.styles.cell);
assert_eq!(fills(bt.options.alt_rows.as_ref().unwrap()), want);
let c = t.cell(0, 0).unwrap();
assert_eq!((c.insets, c.vj, c.fill.as_str()), ([4.0; 4], VerticalJustification::Top, "[None]"));
for e in &c.strokes {
assert_eq!((e.weight, e.color.as_str(), e.tint, &e.kind), (1.0, "[Black]", 1.0, &designcraft_doc::StrokeType::Solid));
}
}
/// IDML has no attribute for a drop cap's character style: it is the nested style list's leading
/// "through 1 Dropcap" entry, read into the drop cap style and written back from it.
#[test]
fn drop_caps_import_and_round_trip() {
// A style with a drop cap and its character style; a paragraph overriding both locally.
let opener = r#"<ParagraphStyle Self="ParagraphStyle/Opener" Name="Opener" DropCapCharacters="1" DropCapLines="3">
<Properties><AllNestedStyles type="list"><ListItem type="record">
<AppliedCharacterStyle type="object">CharacterStyle/Strong</AppliedCharacterStyle><Delimiter type="enumeration">Dropcap</Delimiter>
<Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles></Properties>
</ParagraphStyle>
</RootParagraphStyleGroup>"#;
let styles = STYLES.replace("</RootParagraphStyleGroup>", opener);
let story = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener" DropCapCharacters="2" DropCapLines="2">
<Properties><AllNestedStyles type="list"><ListItem type="record">
<AppliedCharacterStyle type="object">CharacterStyle/$ID/[No character style]</AppliedCharacterStyle><Delimiter type="enumeration">Dropcap</Delimiter>
<Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles></Properties>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Once upon a time</Content></CharacterStyleRange>
</ParagraphStyleRange>
</Story>
</idPkg:Story>"#;
let bytes = zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", &styles),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", story),
]);
let check = |d: &Document| {
let st = d.styles.para("Opener").unwrap();
assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines), (Some(1), Some(3)));
assert_eq!(st.para.drop_cap_style.as_deref(), Some("Strong"));
assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")]), "one entry, not duplicated");
let p = &d.stories.values().find(|s| s.text.starts_with("Once")).unwrap().paras[0];
assert_eq!((p.para.drop_cap_chars, p.para.drop_cap_lines), (Some(2), Some(2)));
assert_eq!(p.para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE));
};
let d = import_idml_with(&bytes, &|_| None).unwrap();
check(&d);
let bytes = export_idml(&d);
assert!(!zip_text(&bytes, "").contains("DropCapStyle"), "not an IDML attribute");
check(&import_idml(&bytes).unwrap());
// A drop cap style set without nested styles is written as one; Align Left Edge and Scale for
// Descenders as DropcapDetail.
let mut d = d;
if let Some(s) = d.styles_mut().para_mut("Opener") {
s.para.nested_styles = None;
s.para.drop_cap_align_left = Some(false);
s.para.drop_cap_scale_descenders = Some(true);
}
let bytes = export_idml(&d);
assert!(!zip_text(&bytes, "").contains("DropCapStyle"));
assert!(zip_text(&bytes, "Resources/Styles.xml").contains(r#"DropcapDetail="2""#));
let back = import_idml(&bytes).unwrap();
let st = back.styles.para("Opener").unwrap();
assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines, st.para.drop_cap_style.as_deref()), (Some(1), Some(3), Some("Strong")));
assert_eq!((st.para.drop_cap_align_left, st.para.drop_cap_scale_descenders), (Some(false), Some(true)));
assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")]));
}
/// IDML styles and stories with an `Opener` style whose drop cap style is `Strong`, plus `styles`
/// (more paragraph styles) and `paras` (the story's paragraph ranges). `WORDS` in them stands for
/// a nested style list of `Strong` through 1 word.
fn drop_cap_fixture(styles: &str, paras: &str) -> Vec<u8> {
let nested = |style: &str, delim: &str| {
format!(
r#"<AllNestedStyles type="list"><ListItem type="record"><AppliedCharacterStyle type="object">CharacterStyle/{style}</AppliedCharacterStyle><Delimiter type="enumeration">{delim}</Delimiter><Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles>"#
)
};
let words = nested("Strong", "AnyWord");
let opener = format!(
r#"<ParagraphStyle Self="ParagraphStyle/Opener" Name="Opener" DropCapCharacters="1" DropCapLines="3"><Properties>{}</Properties></ParagraphStyle>
{}
</RootParagraphStyleGroup>"#,
nested("Strong", "Dropcap"),
styles.replace("WORDS", &words)
);
let styles = STYLES.replace("</RootParagraphStyleGroup>", &opener);
let story = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">{}</Story>
</idPkg:Story>"#,
paras.replace("WORDS", &words)
);
zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", &styles),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", &story),
])
}
/// A nested style list replaces the inherited one, drop cap style entry included: a style or
/// paragraph with a list of its own and no Dropcap entry has no drop cap style.
#[test]
fn own_nested_styles_clear_an_inherited_drop_cap_style() {
let styles = r#"<ParagraphStyle Self="ParagraphStyle/Child" Name="Child"><Properties><BasedOn type="object">ParagraphStyle/Opener</BasedOn>WORDS</Properties></ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Plain" Name="Plain"><Properties>WORDS</Properties></ParagraphStyle>"#;
let paras = r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener"><Properties>WORDS</Properties>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Cleared</Content><Br/></CharacterStyleRange>
</ParagraphStyleRange>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Inherited</Content></CharacterStyleRange>
</ParagraphStyleRange>"#;
let d = import_idml_with(&drop_cap_fixture(styles, paras), &|_| None).unwrap();
let cap = |name: &str| d.styles.para(name).unwrap().para.drop_cap_style.clone();
assert_eq!(cap("Opener").as_deref(), Some("Strong"));
assert_eq!(cap("Child").as_deref(), Some(st::NO_CHAR_STYLE), "its own list replaces Opener's");
assert_eq!(cap("Plain"), None, "nothing to clear");
let story = d.stories.values().find(|s| s.text.starts_with("Cleared")).unwrap();
assert_eq!(story.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE));
assert_eq!(story.paras[1].para.drop_cap_style, None);
assert_eq!(d.styles.resolve_para(&story.paras[1]).0.drop_cap_style, "Strong");
}
/// Paragraphs in table cells and footnotes are cleared like story paragraphs.
#[test]
fn own_nested_styles_clear_an_inherited_drop_cap_style_in_cells_and_notes() {
let opener = |text: &str| {
format!(
r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener"><Properties>WORDS</Properties><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content></CharacterStyleRange></ParagraphStyleRange>"#
)
};
let paras = format!(
r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">
<Content>Body</Content><Footnote>{}</Footnote>
<Table HeaderRowCount="0" FooterRowCount="0"><Row Name="0" MinimumHeight="24"/><Column Name="0" SingleColumnWidth="60"/><Cell Name="0:0">{}</Cell></Table>
</CharacterStyleRange></ParagraphStyleRange>"#,
opener("Note"),
opener("Cell")
);
let d = import_idml_with(&drop_cap_fixture("", &paras), &|_| None).unwrap();
let story = d.stories.values().find(|s| !s.notes.is_empty()).expect("story with a footnote");
let note = &story.notes[0].text;
assert_eq!(note.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", note.text);
let cell = &story.tables.values().next().expect("a table").cell(0, 0).unwrap().text;
assert_eq!(cell.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", cell.text);
}
/// A paragraph that clears its style's drop cap style without nested styles of its own round-trips:
/// it is written with the style's list, the Dropcap entry set to [None].
#[test]
fn a_cleared_drop_cap_style_round_trips() {
let paras = r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Once upon a time</Content></CharacterStyleRange>
</ParagraphStyleRange>"#;
let mut d = import_idml_with(&drop_cap_fixture("", paras), &|_| None).unwrap();
let sid = *d.stories.keys().next().unwrap();
let clear = |d: &mut Document| d.story_mut(sid).unwrap().paras[0].para.drop_cap_style = Some(st::NO_CHAR_STYLE.into());
let resolved = |d: &Document| {
let st = d.stories.values().find(|s| s.text.starts_with("Once")).unwrap();
d.styles.resolve_para(&st.paras[0]).0
};
clear(&mut d);
let back = import_idml(&export_idml(&d)).unwrap();
let pp = resolved(&back);
assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE);
assert_eq!(pp.nested_styles, vec![designcraft_doc::NestedStyle::drop_cap(st::NO_CHAR_STYLE)]);
// The style's drop cap style without nested styles: the paragraph is written with an empty
// list, which clears it on import.
if let Some(s) = d.styles_mut().para_mut("Opener") {
s.para.nested_styles = None;
}
assert_eq!(resolved(&d).drop_cap_style, st::NO_CHAR_STYLE);
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(back.styles.para("Opener").unwrap().para.drop_cap_style.as_deref(), Some("Strong"));
let pp = resolved(&back);
assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE);
assert!(pp.nested_styles.iter().all(|ns| ns.style != "Strong"), "{:?}", pp.nested_styles);
}
/// Frames take the corners and Text Frame Options they don't write from their object style chain,
/// then `[None]`. Per-corner attributes win over the legacy all-corners pair, at each level; a
/// corner shape without a radius takes the chain's radius.
#[test]
fn frames_inherit_object_style_corners_and_text_frame_options() {
use designcraft_doc::{AutoSize, FirstBaseline, VerticalJustification};
use designcraft_geom::corners::CornerShape as C;
// Anchors in the order top-left, bottom-left, bottom-right, top-right.
let path = |x: f64| {
let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties>"#
)
};
let corners = |opt: &str, r: &str| {
["TopLeft", "TopRight", "BottomLeft", "BottomRight"].map(|n| format!(r#"{n}CornerOption="{opt}" {n}CornerRadius="{r}""#)).join(" ")
};
let inset = |v: f64| {
format!(
r#"<Properties><InsetSpacing type="list">{}</InsetSpacing></Properties>"#,
r#"<ListItem type="unit">V</ListItem>"#.replace('V', &v.to_string()).repeat(4)
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="20.2" Self="d">
<RootObjectStyleGroup Self="ro">
<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]" {none}>
<TextFramePreference TextColumnCount="1" TextColumnGutter="12" AutoSizingReferencePoint="CenterPoint" AutoSizingType="Off"/>
</ObjectStyle>
<ObjectStyle Self="ObjectStyle/Rounded" Name="Rounded" TopLeftCornerOption="RoundedCorner" TopRightCornerOption="RoundedCorner" BottomLeftCornerOption="RoundedCorner" BottomRightCornerOption="RoundedCorner" TopLeftCornerRadius="6">
<Properties><BasedOn type="object">ObjectStyle/$ID/[None]</BasedOn></Properties>
<TextFramePreference TextColumnCount="2" TextColumnGutter="18" VerticalJustification="CenterAlign" FirstBaselineOffset="LeadingOffset" MinimumFirstBaselineOffset="4" IgnoreWrap="true" AutoSizingType="HeightOnly">{inset}</TextFramePreference>
</ObjectStyle>
<ObjectStyle Self="ObjectStyle/Child" Name="Child" TopRightCornerOption="BevelCorner">
<Properties><BasedOn type="object">ObjectStyle/Rounded</BasedOn></Properties>
</ObjectStyle>
</RootObjectStyleGroup>
<Story Self="s1"/>
<Spread Self="sp1"><Page Self="p1"/>
<TextFrame Self="tf" ParentStory="s1" AppliedObjectStyle="ObjectStyle/Child" BottomRightCornerOption="InverseRoundedCorner" BottomRightCornerRadius="11.34">{p0}<TextFramePreference TextColumnCount="3"/></TextFrame>
<Rectangle Self="legacy" AppliedObjectStyle="ObjectStyle/Rounded" CornerOption="BevelCorner" CornerRadius="5" TopLeftCornerOption="InsetCorner">{p1}</Rectangle>
<Rectangle Self="one" TopRightCornerOption="RoundedCorner" TopRightCornerRadius="11.34">{p2}</Rectangle>
<Rectangle Self="plain" AppliedObjectStyle="ObjectStyle/Rounded">{p3}</Rectangle>
</Spread>
</Document>"#,
none = corners("None", "12"),
inset = inset(3.0),
p0 = path(0.0),
p1 = path(100.0),
p2 = path(200.0),
p3 = path(300.0),
);
let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap();
let item = |n: usize| d.spreads[0].items[n].clone();
// Path order: top-left, bottom-left, bottom-right, top-right.
let shapes = |n: usize| item(n).corners.corners.map(|c| (c.shape, c.size));
assert_eq!(shapes(0), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::InverseRounded, 11.34), (C::Bevel, 12.0)]);
assert_eq!(shapes(1), [(C::Inset, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0)]);
assert!(item(2).corners.corners.iter().enumerate().all(|(i, c)| (c.shape == C::Rounded) == (i == 3)));
assert_eq!(item(2).corners.corners[3].size, 11.34);
assert_eq!(shapes(3), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::Rounded, 12.0), (C::Rounded, 12.0)]);
let tf = item(0);
let o = &tf.text_frame().unwrap().options;
assert_eq!((o.columns, o.gutter, o.inset), (3, 18.0, [3.0; 4]));
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");
}

View File

@ -1,240 +0,0 @@
//! 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"));
}

View File

@ -55,27 +55,10 @@ impl Remote {
Err(last)
}
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));
}
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
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"));
};
@ -95,10 +78,6 @@ 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) => {
@ -128,98 +107,6 @@ 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();
@ -262,7 +149,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.unwrap_err().contains("operation may have completed"), "lost reply must report an uncertain outcome");
assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}");
assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully");
}
}

View File

@ -123,8 +123,7 @@ impl Headless {
let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?;
let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?;
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base);
let clicks = e.get("clicks").and_then(Value::as_u64).map_or(1, |n| n.clamp(1, 255) as u8);
self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks }, VIEW).map_err(|e| e.to_string())?;
self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods }, VIEW).map_err(|e| e.to_string())?;
}
let requests: Vec<Value> = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect();
Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests}))
@ -202,7 +201,7 @@ impl Headless {
let png = img.to_png();
match s(p, "path") {
Some(path) => {
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
std::fs::write(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 +221,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() };
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?;
std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?;
Ok(json!({"path": path, "width": img.width, "height": img.height}))
}
}

View File

@ -279,7 +279,6 @@ pub fn tool_definitions() -> Vec<Value> {
"y": num("y (pt)"),
"space": {"type": "string", "enum": ["doc", "screen"], "description": "Coordinate space (default doc)"},
"mods": mods_schema(),
"clicks": {"type": "integer", "minimum": 1, "description": "On a down: presses in a row it completes (2 double, 3 triple…; default 1)"},
}), &["kind", "x", "y"]),
},
"mods": mods_schema(),
@ -480,7 +479,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);
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?;
}
Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path})))
}

View File

@ -202,18 +202,6 @@ fn tables_export_cell_text() {
}
}
#[test]
fn drop_cap_text_extracts_in_order() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let para = designcraft_doc::ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..Default::default() };
let text = "Drop caps read in order with the rest of the paragraph, which runs on for a few lines beside the enlarged letters.";
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 236.0, 300.0), lid, text, ParaFormat { para, ..Default::default() }).unwrap();
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let flat: String = extract_text(&bytes)[0].split_whitespace().collect::<Vec<_>>().join(" ");
assert!(flat.starts_with("Drop caps read in order"), "{flat}");
}
/// A 4×2 CMYK TIFF: the left half 100% K (text in a scan), the right half C0 M100 Y100 K0.
fn cmyk_tiff() -> (Vec<u8>, Vec<u8>) {
let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]);

View File

@ -1246,37 +1246,6 @@ mod tests {
assert!(r.stats.glyphs >= 15);
}
/// An inside-aligned stroke is the stroke of the path inset by half its weight: a band from the
/// edge inwards, its rounded corners concentric with the frame's.
#[test]
fn inside_strokes_are_inset_by_half_the_weight() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)));
it.stroke = designcraft_doc::Stroke {
swatch: designcraft_color::swatch::BLACK.into(),
weight: 10.0,
align: designcraft_doc::StrokeAlign::Inside,
..Default::default()
};
it.corners = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 20.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
let ink = |x: u32, y: u32| img.pixel(x, y)[0] < 64;
let paper = |x: u32, y: u32| img.pixel(x, y)[0] > 192;
// Straight edges: ink from the edge to 10 pt inside, nothing outside.
assert!(paper(97, 200) && ink(101, 200) && ink(108, 200) && paper(112, 200));
assert!(ink(298, 200) && paper(302, 200) && paper(288, 200));
// Rounded corner centred at (120, 120): ink between radii 10 and 20, paper outside the arc
// and inside the band.
assert!(paper(103, 103) && ink(108, 108) && paper(114, 114));
}
#[test]
fn arrowheads_draw_at_line_ends() {
let mut d = Document::new(&NewDocument::default());

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<(String, f32, Rect)> = Vec::new();
let mut strikes: Vec<(String, f32, Rect)> = Vec::new();
let mut underlines = Vec::new();
let mut strikes = Vec::new();
let mut glyphs = 0;
for g in &l.glyphs {
if !g.visible {
@ -127,21 +127,7 @@ 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 {
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)),
}
bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g))));
}
}
let outline = db.outline(&g.face, g.gid);
@ -192,9 +178,7 @@ fn line_box(l: &Line) -> Option<Rect> {
let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min);
let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max);
let pad = l.ascent.max(1.0);
// A drop cap reaches down past its line.
let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0)));
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad))
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, l.baseline + l.descent.max(0.0) + pad))
}
impl Renderer {

View File

@ -99,7 +99,6 @@ 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();
@ -152,7 +151,6 @@ 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());
@ -189,7 +187,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 !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) {
if paras.len() == 1 && paras[0].1.is_empty() {
i += 1;
continue;
}
@ -245,18 +243,6 @@ 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";

Some files were not shown because too many files have changed in this diff Show More