Merge remote-tracking branch 'origin/main' into properties-more-options

# Conflicts:
#	ROADMAP.md
This commit is contained in:
moppeeer
2026-10-22 09:03:44 +02:00
62 changed files with 2997 additions and 327 deletions
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+1
View File
@@ -26,6 +26,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`).
Generated
+34 -21
View File
@@ -683,6 +683,15 @@ dependencies = [
"libc",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -933,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -955,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -971,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -983,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1003,8 +1012,9 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"chrono",
"designcraft-color",
"designcraft-geom",
"proptest",
@@ -1015,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1054,8 +1064,9 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"chrono",
"designcraft-color",
"designcraft-doc",
"zip",
@@ -1063,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1076,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1088,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1101,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1116,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1131,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1144,8 +1155,9 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"chrono",
"craft-pdf",
"designcraft-color",
"designcraft-compose",
@@ -1166,8 +1178,9 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"chrono",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@@ -1183,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1202,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1211,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1223,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1250,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5801,7 +5814,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde_json",
]
+3 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -39,6 +39,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
rayon = "1"
unicode-bidi = "0.3"
+3
View File
@@ -348,6 +348,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
+22 -19
View File
@@ -90,27 +90,16 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -317,6 +306,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+49 -15
View File
@@ -27,6 +27,9 @@ pub struct NativeMenu {
shown_for: (String, bool),
/// File › Open Recent, rebuilt when the list of recent files changes.
recent: Option<RecentMenu>,
/// The commands with key equivalents and the shortcut each was given: the menu is rebuilt
/// when one changes, so a shortcut moved to another command leaves its old item.
keys: Vec<(String, Option<String>)>,
}
struct RecentMenu {
@@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String {
menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label))
}
/// A command's default shortcut, as the in-window menus show it.
fn shortcut(id: &str) -> Option<&'static str> {
menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut))
/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it.
fn shortcut(app: &DesignApp, id: &str) -> Option<String> {
menus::shortcut_of(app, id)
}
impl NativeMenu {
pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self {
// A click in the menu bar reaches the app with no input over the window, so nothing would
// draw the frame that runs the command until the pointer or a key comes back: wake egui.
// muda keeps the first handler for the life of the process, so this channel serves every
// menu bar built later (`build_bar`).
let (tx, events) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
repaint.request_repaint();
}));
Self::build_bar(app, events)
}
/// The menu bar for the current language and settings, fed by `events`.
fn build_bar(app: &mut DesignApp, events: Receiver<MenuEvent>) -> Self {
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
@@ -75,12 +85,24 @@ impl NativeMenu {
for entry in menus::MAC_APP_MENU {
let _ = match *entry {
AppMenuItem::Cmd { id, label, command } => {
let sc = shortcut(command).and_then(accel);
let sc = shortcut(app, command).as_deref().and_then(accel);
let item = MenuItem::with_id(id, menu_title(app, label), true, sc);
let appended = app_menu.append(&item);
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
appended
}
AppMenuItem::Language => {
let sub = Submenu::new(menu_title(app, "Language"), true);
for (code, name) in designcraft_ui_egui::i18n::LANGUAGES {
let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code });
let params = serde_json::json!({ "lang": code });
let checked = menus::checked(app, "app.language", &params).unwrap_or(false);
let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None);
let _ = sub.append(&item);
items.insert(id, ("app.language".into(), params, Handle::Check(item)));
}
app_menu.append(&sub)
}
AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()),
AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)),
AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)),
@@ -96,15 +118,19 @@ impl NativeMenu {
}
menu.init_for_nsapp();
app.native_menu = true;
app.native_shortcuts = items
.values()
.filter_map(|(id, p, _)| {
let sc = shortcut(id)?;
(p.is_null() && accel(sc).is_some()).then(|| id.clone())
})
.collect();
let keys: Vec<(String, Option<String>)> =
items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect();
app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect();
let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None });
Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent }
Self {
_menu: menu,
items,
events,
last_refresh: 0.0,
shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)),
recent,
keys,
}
}
/// Put the current recent files at the top of File › Open Recent when they changed.
@@ -153,13 +179,21 @@ impl NativeMenu {
// back by Cancel): the system menu bar follows at once, like the panels (the new menu
// replaces the old one as the application's menu before that one is dropped).
if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 {
*self = Self::install(app, ctx);
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
}
let now = designcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
// A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the
// menu bar before the window sees the key, so the bar must carry the new keys.
if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) {
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
return;
}
self.refresh_recent(app);
for (cmd, params, h) in self.items.values() {
let en = menus::native_menu_enabled(app, ctx, cmd);
@@ -199,10 +233,10 @@ fn build(
}
// Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes.
Item::Cmd { id, .. } if !menus::shown(app, id) => {}
Item::Cmd { label, id, params, shortcut } => {
Item::Cmd { label, id, params, .. } => {
*counter += 1;
let mid = format!("dc{counter}");
let sc = if params.is_null() { shortcut.and_then(accel) } else { None };
let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None };
let label = menu_title(app, label);
let handle = if menus::checked(app, id, params).is_some() {
let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc);
+3 -2
View File
@@ -1860,11 +1860,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None };
}
// Rules and shading for the paragraph.
if let Some((fi, bl, asc, _)) = first_line_rect {
if let Some((fi, bl, _, _)) = first_line_rect {
let ft = &mut out.frames[fi];
if pp.rule_above.on {
let r = &pp.rule_above;
let y = bl - asc - r.offset;
// The offset runs from the first line's baseline up to the rule's bottom edge.
let y = bl - r.offset;
let col = ft
.lines
.iter()
+20
View File
@@ -7290,3 +7290,23 @@ fn greek_text_gets_greek_hyphenation() {
let el = hyphen::Lang::for_language("Greek").unwrap();
assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]);
}
/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in
/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above
/// (an INDD project list whose rules crossed the previous paragraph's text).
#[test]
fn rule_above_offset_is_measured_from_the_baseline() {
use designcraft_doc::Rule;
let rule = Rule { on: true, weight: 1.5, offset: 10.0, ..Default::default() };
let para = ParaAttrs { rule_above: Some(rule), ..Default::default() };
let (d, sid, fid) = doc_with("Row one\nRow two", Rect::new(0.0, 0.0, 300.0, 200.0), para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let (first, second) = (lines[0], lines[1]);
let mut decos: Vec<Rect> = cs.frame(fid).unwrap().decos.iter().map(|dc| dc.rect).collect();
decos.sort_by(|a, b| a.y0.total_cmp(&b.y0));
let rule = decos[1];
assert!((rule.y1 - (second.baseline - 10.0)).abs() < 1e-6, "{rule:?}, baseline {}", second.baseline);
assert!((rule.height() - 1.5).abs() < 1e-6);
assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
}
+1
View File
@@ -14,6 +14,7 @@ designcraft-color = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+81 -2
View File
@@ -343,6 +343,8 @@ impl Document {
let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
let mut parity: Vec<bool> = Vec::new();
let mut kept: Vec<(usize, Arc<Spread>)> = Vec::new();
// Whether each shuffled page was a left page, to mirror its dragged columns if it moves.
let mut was_left: BTreeMap<PageId, bool> = BTreeMap::new();
let mut abs = 0;
for sp in self.spreads.drain(..) {
if !sp.allow_shuffle && sp.pages.len() > 1 {
@@ -350,8 +352,9 @@ impl Document {
kept.push((pages.len(), sp));
continue;
}
for _ in &sp.pages {
for p in &sp.pages {
parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0));
was_left.insert(p.id, p.side == PageSide::Left);
abs += 1;
}
let mut sp = Arc::unwrap_or_clone(sp);
@@ -445,6 +448,19 @@ impl Document {
out.insert(at, sp);
}
self.spreads = out;
// A page that crosses the spine keeps its inside and outside margins, so its margin box
// mirrors; its dragged column guides mirror with it, as in InDesign.
let crossed = |p: &Page| was_left.get(&p.id).is_some_and(|&l| l != (p.side == PageSide::Left)) && p.columns.positions.is_some();
for sp in &mut self.spreads {
if !sp.pages.iter().any(crossed) {
continue;
}
for p in &mut Arc::make_mut(sp).pages {
if crossed(&*p) {
p.columns = p.columns.mirrored(p.margin_rect().width());
}
}
}
// Pages can change sides, and so parent pages.
self.sync_parent_margins();
}
@@ -466,6 +482,10 @@ impl Document {
if let Some(t) = &template {
p.width = t.width;
p.height = t.height;
// Dragged column guides too: the same width and margins keep them valid.
// They are taken as for this single page; repaginating mirrors them if the
// page lands on a left page.
p.columns.positions = p.columns_from(t).positions;
}
ids.push(p.id);
p
@@ -675,7 +695,7 @@ impl Document {
if page.local_margins {
continue;
}
let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, q.columns.clone())) else {
let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, page.columns_from(q))) else {
continue;
};
if let Some(page) = Arc::make_mut(&mut self.spreads[si]).pages.get_mut(pi) {
@@ -879,6 +899,65 @@ mod tests {
assert!(d.delete_pages(&[0, 1]).is_err());
}
/// Pages inserted after a page with dragged column guides keep them, as they keep its column
/// count and gutter; pages that follow a parent take the parent's.
#[test]
fn inserted_pages_keep_dragged_columns() {
let dragged = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
let widths = |p: &Page| p.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>();
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0];
(pg.columns, pg.local_margins) = (dragged.clone(), true);
d.insert_pages(Some(0), 2, None).unwrap();
for i in 1..3 {
let p = d.page(i).unwrap();
assert_eq!(p.columns, dragged, "page {i}");
assert_eq!(widths(p), vec![100.0, 410.0], "page {i}");
}
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged.clone();
d.sync_parent_margins();
d.insert_pages(Some(0), 1, d.parents.first().map(|p| p.id)).unwrap();
assert_eq!(d.page(1).map(widths), Some(vec![100.0, 410.0]));
}
/// A right page whose gutter was dragged toward the spine keeps it there when inserting a
/// page before it makes it a left page: its dragged columns mirror across the margin box.
#[test]
fn dragged_columns_mirror_when_a_page_changes_side() {
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: true, ..Default::default() });
let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0];
assert_eq!(pg.side, PageSide::Right);
let w = pg.margin_rect().width();
(pg.columns, pg.local_margins) = (Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 112.0, w]) }, true);
let id = pg.id;
// How far the gutter's spine-side edge is from the margin on the spine side.
let from_spine = |p: &Page| {
let (m, c) = (p.margin_rect(), p.column_rects());
match p.side {
PageSide::Left => m.x1 - c[1].x0,
_ => c[0].x1 - m.x0,
}
};
assert!((from_spine(d.page(0).unwrap()) - 100.0).abs() < 1e-9);
d.insert_pages(None, 1, None).unwrap();
let moved = d.page(1).unwrap();
assert_eq!((moved.id, moved.side), (id, PageSide::Left));
assert!((from_spine(moved) - 100.0).abs() < 1e-9, "{:?}", moved.column_rects());
let widths: Vec<f64> = moved.column_rects().iter().map(|r| r.width()).collect();
assert!((widths[0] - (w - 112.0)).abs() < 1e-9 && (widths[1] - 100.0).abs() < 1e-9, "{widths:?}");
// The inserted page is a right page like the one it copies: no mirroring.
assert_eq!(d.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 112.0, w]));
// Moving it back to the right undoes the mirror.
d.delete_pages(&[0]).unwrap();
let back = d.page(0).unwrap();
assert_eq!(back.side, PageSide::Right);
assert!((from_spine(back) - 100.0).abs() < 1e-9, "{:?}", back.column_rects());
}
#[test]
fn items_travel_with_pages() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
+10
View File
@@ -35,6 +35,7 @@ pub mod story;
pub mod styles;
mod swatch_refs;
pub mod table;
pub mod time;
pub mod vars;
pub mod xref;
@@ -398,11 +399,19 @@ pub struct DocSettings {
/// Lock Zero Point: dragging from the ruler corner leaves it where it is.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub zero_point_locked: bool,
/// View › Grids & Guides › Lock Column Guides (IDML `ColumnGuideLocked`): column guides can't
/// be dragged. On in new documents, as in InDesign.
#[serde(skip_serializing_if = "is_true")]
pub column_guides_locked: bool,
/// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets
/// with nothing selected (InDesign's document defaults).
pub new_object: NewObjectColors,
}
fn is_true(b: &bool) -> bool {
*b
}
fn is_origin(p: &[f64; 2]) -> bool {
p[0] == 0.0 && p[1] == 0.0
}
@@ -460,6 +469,7 @@ impl Default for DocSettings {
ruler_origin: RulerOrigin::Spread,
zero_point: [0.0, 0.0],
zero_point_locked: false,
column_guides_locked: true,
new_object: NewObjectColors::default(),
}
}
+214 -5
View File
@@ -42,6 +42,86 @@ impl Default for Columns {
}
}
/// The most columns a page can have, as in InDesign.
pub const MAX_COLUMNS: u32 = 216;
/// How far, in points, the first and last custom column positions may sit from the margin box's
/// edges: room for float noise in files and page arithmetic, far less than any dragged guide.
pub const COLUMN_POSITION_TOLERANCE: f64 = 0.01;
/// Whether `positions` are usable custom column positions for `count` columns across a margin box
/// `margin_width` wide, in IDML's `ColumnsPositions` form: each column's left and right edge as an
/// offset from the margin box's left edge, so `2 × count` finite numbers, ascending across the
/// whole list, the first at 0 and the last at `margin_width` (within
/// [`COLUMN_POSITION_TOLERANCE`]).
pub fn valid_column_positions(positions: &[f64], count: u32, margin_width: f64) -> bool {
(1..=MAX_COLUMNS).contains(&count)
&& positions.len() == 2 * count as usize
&& margin_width.is_finite()
&& positions.iter().all(|v| v.is_finite())
&& positions.windows(2).all(|w| matches!(w, [a, b] if a <= b))
&& positions.first().is_some_and(|v| v.abs() <= COLUMN_POSITION_TOLERANCE)
&& positions.last().is_some_and(|v| (v - margin_width).abs() <= COLUMN_POSITION_TOLERANCE)
}
impl Columns {
/// The custom positions when they are valid for a margin box `margin_width` wide (see
/// [`valid_column_positions`]). Anything else means the columns are evenly spaced: a stored set
/// that no longer fits (a page resized after the drag, a hostile file) is not drawn.
pub fn valid_positions(&self, margin_width: f64) -> Option<&[f64]> {
let positions = self.positions.as_deref()?;
valid_column_positions(positions, self.count, margin_width).then_some(positions)
}
/// Every column's left and right edge as offsets from the margin box's left edge: the valid
/// custom positions, else the even split. This is what IDML writes as `ColumnsPositions`.
pub fn edges(&self, margin_width: f64) -> Vec<f64> {
let w = if margin_width.is_finite() { margin_width.max(0.0) } else { 0.0 };
match self.valid_positions(w) {
Some(p) => p.iter().map(|&v| inside(v, w)).collect(),
None => column_rects(Rect::new(0.0, 0.0, w, 0.0), self.count.max(1), self.gutter).iter().flat_map(|r| [r.x0, r.x1]).collect(),
}
}
/// The column edges (as [`Columns::edges`]) with gutter `gutter` (0 is the one between the first
/// two columns) moved so its left edge sits at `new_left`, an offset from the margin box's left
/// edge. Both of the gutter's guides move, so it keeps its width, and it stops at its
/// neighbouring gutters or the margins: no column gets a negative width. `None` when there is
/// no such gutter, `new_left` is not finite, or the result would not be valid.
pub fn with_gutter_moved(&self, margin_width: f64, gutter: usize, new_left: f64) -> Option<Vec<f64>> {
if !new_left.is_finite() {
return None;
}
let mut pos = self.edges(margin_width);
let i = gutter.checked_mul(2)?.checked_add(1)?;
let &[lo, left, right, hi] = pos.get(i - 1..i.checked_add(3)?)? else {
return None;
};
let width = right - left;
let at = new_left.min(hi - width).max(lo);
let [l, r] = pos.get_mut(i..i + 2)? else {
return None;
};
*l = at;
*r = (at + width).min(hi);
valid_column_positions(&pos, self.count, margin_width).then_some(pos)
}
/// These columns reflected across a margin box `margin_width` wide, as on the facing page
/// across the spine: each custom position `p` becomes `margin_width - p`, in reverse order so
/// they stay ascending. Count and gutter stay; positions that are not valid (see
/// [`Columns::valid_positions`]) become `None`, the even split, which mirrors onto itself.
pub fn mirrored(&self, margin_width: f64) -> Columns {
let positions = self.valid_positions(margin_width).map(|p| p.iter().rev().map(|&v| inside(margin_width - v, margin_width)).collect());
Columns { count: self.count, gutter: self.gutter, positions }
}
}
/// `v` limited to `0..=w` without `clamp`, which panics when `w` is NaN.
fn inside(v: f64, w: f64) -> f64 {
v.max(0.0).min(w)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Orientation {
@@ -233,9 +313,10 @@ impl Page {
pub fn bounds(&self) -> Rect {
Rect::new(self.x, 0.0, self.x + self.width, self.height)
}
/// Same margins and columns as `other`.
/// Same margins and columns as `other`, with `other`'s columns as it would take them from this
/// page: mirrored when one is a left page and the other isn't.
pub fn same_margins(&self, other: &Page) -> bool {
self.margins == other.margins && self.columns == other.columns
self.margins == other.margins && other.columns_from(self) == other.columns
}
/// Left and right margins resolved for the page side (inside is at the spine).
pub fn left_right_margins(&self) -> (f64, f64) {
@@ -244,23 +325,39 @@ impl Page {
_ => (self.margins.inside, self.margins.outside),
}
}
/// `src`'s columns as they fall on this page: the same when both pages put their inside
/// margin on the same physical side, mirrored across `src`'s margin box when one is a left
/// page and the other is not, as InDesign mirrors dragged column guides across the spine.
pub fn columns_from(&self, src: &Page) -> Columns {
if (self.side == PageSide::Left) == (src.side == PageSide::Left) {
src.columns.clone()
} else {
src.columns.mirrored(src.margin_rect().width())
}
}
/// The live area inside the margins.
pub fn margin_rect(&self) -> Rect {
let (l, r) = self.left_right_margins();
let b = self.bounds();
Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top))
}
/// Column rects within the margins.
/// Column rects within the margins: the custom positions when the column guides have been
/// dragged and still fit the margin box (see [`valid_column_positions`]), otherwise an even
/// split.
pub fn column_rects(&self) -> Vec<Rect> {
let m = self.margin_rect();
column_rects(m, self.columns.count.max(1), self.columns.gutter)
let w = m.width();
match self.columns.valid_positions(w) {
Some(p) => p.as_chunks::<2>().0.iter().map(|&[a, b]| Rect::new(m.x0 + inside(a, w), m.y0, m.x0 + inside(b, w), m.y1)).collect(),
None => column_rects(m, self.columns.count.max(1), self.columns.gutter),
}
}
}
/// 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.clamp(1, MAX_COLUMNS) 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()
}
@@ -450,6 +547,118 @@ mod tests {
assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9);
}
/// Dragged column guides place the columns; a set that cannot mean anything falls back to the
/// even split rather than producing nothing or a column outside the margins (#1096).
#[test]
fn custom_column_positions_place_the_columns() {
let mut p = page(PageSide::Single, 0.0);
let m = p.margin_rect();
let w = m.width();
let even = p.column_rects();
p.columns.positions = Some(vec![0.0, 100.0, 130.0, w]);
assert_eq!(p.column_rects(), vec![Rect::new(m.x0, m.y0, m.x0 + 100.0, m.y1), Rect::new(m.x0 + 130.0, m.y0, m.x1, m.y1)]);
// Float noise at the edges is absorbed and kept inside the box.
p.columns.positions = Some(vec![-0.001, 100.0, 130.0, w + 0.001]);
assert_eq!((p.column_rects()[0].x0, p.column_rects()[1].x1), (m.x0, m.x1));
let huge: Vec<f64> = (0..10_000).map(f64::from).collect();
let unusable = [
vec![],
vec![10.0],
vec![0.0, w], // one column's worth for two columns
vec![0.0, 100.0, 130.0, 200.0, 230.0, w], // three columns' worth
vec![0.0, 100.0, 90.0, w], // descending across pairs
vec![0.0, 130.0, 100.0, w], // an inverted gutter
vec![0.0, f64::NAN, 130.0, w],
vec![0.0, 100.0, f64::INFINITY, w],
vec![5.0, 100.0, 130.0, w], // not from the left margin
vec![0.0, 100.0, 130.0, w + 500.0], // a page narrowed after the drag
vec![0.0, 100.0, 130.0, w - 50.0], // a page widened after the drag
huge,
];
for bad in unusable {
p.columns.positions = Some(bad.clone());
assert_eq!(p.column_rects(), even, "{:?}", bad.iter().take(6).collect::<Vec<_>>());
assert_eq!(p.columns.edges(w), even.iter().flat_map(|r| [r.x0 - m.x0, r.x1 - m.x0]).collect::<Vec<_>>());
}
p.columns.positions = None;
assert_eq!(p.column_rects(), even);
// A count outside 1..=216 has no valid positions, however many are given.
for count in [0, 217, u32::MAX] {
let c = Columns { count, gutter: 0.0, positions: Some(vec![0.0, w]) };
assert!(c.valid_positions(w).is_none(), "{count}");
}
}
/// Moving a gutter moves both its guides, keeps its width and stops at the neighbouring gutters
/// and the margins; the result is valid custom positions.
#[test]
fn with_gutter_moved_moves_a_gutter_pair_and_clamps() {
// Three columns across 450 with gutter 15: 0 140 155 295 310 450.
let c = Columns { count: 3, gutter: 15.0, positions: None };
let even = c.edges(450.0);
assert_eq!(even, vec![0.0, 140.0, 155.0, 295.0, 310.0, 450.0]);
assert_eq!(c.with_gutter_moved(450.0, 0, 100.0), Some(vec![0.0, 100.0, 115.0, 295.0, 310.0, 450.0]));
// Past the left margin: the first column closes to nothing.
assert_eq!(c.with_gutter_moved(450.0, 0, -50.0), Some(vec![0.0, 0.0, 15.0, 295.0, 310.0, 450.0]));
// Past the next gutter: the middle column closes to nothing, the next gutter stays put.
assert_eq!(c.with_gutter_moved(450.0, 0, 400.0), Some(vec![0.0, 280.0, 295.0, 295.0, 310.0, 450.0]));
// The last gutter stops at the right margin.
assert_eq!(c.with_gutter_moved(450.0, 1, 1000.0), Some(vec![0.0, 140.0, 155.0, 435.0, 450.0, 450.0]));
// It starts from custom positions already there, and its result is accepted as custom.
let moved = Columns { positions: c.with_gutter_moved(450.0, 0, 100.0), ..c.clone() };
assert!(moved.valid_positions(450.0).is_some());
assert_eq!(moved.with_gutter_moved(450.0, 1, 200.0), Some(vec![0.0, 100.0, 115.0, 200.0, 215.0, 450.0]));
// No such gutter, or no position to move it to.
assert_eq!(c.with_gutter_moved(450.0, 2, 100.0), None);
assert_eq!(c.with_gutter_moved(450.0, usize::MAX, 100.0), None);
assert_eq!(c.with_gutter_moved(450.0, 0, f64::NAN), None);
assert_eq!(Columns { count: 1, gutter: 12.0, positions: None }.with_gutter_moved(450.0, 0, 100.0), None);
assert_eq!(c.with_gutter_moved(f64::NAN, 0, 100.0), None);
}
/// Mirroring reflects dragged columns across the margin box and keeps them ascending; an
/// invalid set becomes the even split.
#[test]
fn mirrored_columns_reflect_across_the_margin_box() {
let two = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
assert_eq!(two.mirrored(540.0), Columns { positions: Some(vec![0.0, 410.0, 440.0, 540.0]), ..two.clone() });
assert_eq!(two.mirrored(540.0).mirrored(540.0), two);
let three = Columns { count: 3, gutter: 10.0, positions: Some(vec![0.0, 50.0, 60.0, 200.0, 210.0, 300.0]) };
let m = three.mirrored(300.0);
assert_eq!(m.positions, Some(vec![0.0, 90.0, 100.0, 240.0, 250.0, 300.0]));
assert!(m.valid_positions(300.0).is_some());
// Positions that do not fit the margin box, or none at all, mirror to the even split.
assert_eq!(two.mirrored(600.0).positions, None);
assert_eq!(two.mirrored(f64::NAN).positions, None);
let unsorted = Columns { positions: Some(vec![0.0, 130.0, 100.0, 540.0]), ..two.clone() };
assert_eq!(unsorted.mirrored(540.0).positions, None);
let even = Columns { positions: None, ..two.clone() };
assert_eq!(even.mirrored(540.0), even);
// A left page takes a right page's columns mirrored, a page on the same side as they are.
let mut r = page(PageSide::Right, 612.0);
r.columns = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 522.0]) };
let l = page(PageSide::Left, 0.0);
assert_eq!(l.columns_from(&r).positions, Some(vec![0.0, 392.0, 422.0, 522.0]));
assert_eq!(page(PageSide::Right, 0.0).columns_from(&r), r.columns);
// A left page holding them mirrored has the same margins as that right page (a one-page
// parent in a facing document); holding them unmirrored, it doesn't.
let mut l = page(PageSide::Left, 0.0);
l.columns = l.columns_from(&r);
assert!(r.same_margins(&l));
l.columns = r.columns.clone();
assert!(!r.same_margins(&l));
}
#[test]
fn spread_spine_and_hit() {
let s = Spread {
+14
View File
@@ -2,9 +2,19 @@
use serde::{Deserialize, Serialize};
use crate::edit::SpreadRef;
use crate::ids::{ItemId, StoryId};
use crate::table::CellRange;
/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuideSel {
pub spread: SpreadRef,
pub page: usize,
pub index: usize,
}
/// A table cell: the table (in the selection's story) and the owning cell's grid position.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -83,6 +93,10 @@ pub struct Selection {
/// The page selected with the Page tool (absolute index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<usize>,
/// Selected ruler guides. Never together with items, text or cells: selecting one clears
/// the other.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<GuideSel>,
}
impl Selection {
+74
View File
@@ -0,0 +1,74 @@
//! Unix time → proleptic Gregorian calendar, exact for every `i64`.
//!
//! chrono is the only calendar logic. `NaiveDate` covers about ±262143 years, but clocks and files
//! can hold instants past that, so these extend its range: the Gregorian calendar repeats every
//! 400 years (146_097 days), so the instant is moved into chrono's range by whole cycles, chrono
//! gives the month, day and time of day, and the cycles are added back to the year (at most about
//! 2.5e16, so it fits an `i64`). Callers own only their output format and any year clamp.
use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime};
/// Days since 1970-01-01 → (proleptic Gregorian year, a chrono date with the same month and day;
/// its own year is not the real one).
pub fn date_parts(days: i64) -> (i64, NaiveDate) {
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// 1970-01-01 is CE day 719_163; one cycle on from it is well inside chrono's range.
let d = NaiveDate::from_num_days_from_ce_opt(rest as i32 + 719_163).unwrap_or_default();
(i64::from(d.year()) + cycles * 400, d)
}
/// Unix seconds (+ nanos, clamped to 999_999_999) → (proleptic Gregorian year, a chrono date and
/// time with the same month, day and time of day; its own year is not the real one).
pub fn utc_parts(secs: i64, nanos: u32) -> (i64, NaiveDateTime) {
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (y, d) = date_parts(days);
let t = NaiveTime::from_num_seconds_from_midnight_opt(sod as u32, nanos.min(999_999_999)).unwrap_or_default();
(y, d.and_time(t))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Timelike;
fn ymdhms(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let (y, t) = utc_parts(secs, 0);
(y, t.month(), t.day(), t.hour(), t.minute(), t.second())
}
#[test]
fn epoch_and_ordinary_dates() {
assert_eq!(ymdhms(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(ymdhms(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(ymdhms(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
}
#[test]
fn before_1970() {
assert_eq!(ymdhms(-1), (1969, 12, 31, 23, 59, 59));
assert_eq!(ymdhms(-86_400), (1969, 12, 31, 0, 0, 0));
assert_eq!(ymdhms(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
}
#[test]
fn year_9999_rolls_into_10000() {
assert_eq!(ymdhms(253_402_300_799), (9999, 12, 31, 23, 59, 59));
assert_eq!(ymdhms(253_402_300_800), (10000, 1, 1, 0, 0, 0));
}
#[test]
fn i64_extremes_stay_exact() {
assert_eq!(ymdhms(i64::MAX), (292277026596, 12, 4, 15, 30, 7));
assert_eq!(ymdhms(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
// Days (not seconds) at the extremes: whole cycles still fit, so the year can't overflow.
let ((hi, _), (lo, _)) = (date_parts(i64::MAX), date_parts(i64::MIN));
assert!(hi > 25_000_000_000_000_000 && hi < i64::MAX && lo < -25_000_000_000_000_000 && lo > i64::MIN);
}
#[test]
fn nanos_past_a_second_are_clamped() {
assert_eq!(utc_parts(0, 999_999_999).1.nanosecond(), 999_999_999);
assert_eq!(utc_parts(0, 1_500_000_000).1.nanosecond(), 999_999_999);
assert_eq!(utc_parts(0, u32::MAX).1.second(), 0);
}
}
+48 -18
View File
@@ -126,28 +126,34 @@ pub fn now() -> i64 {
}
}
/// (year, month 1–12, day 1–31) from days since 1970-01-01 (H. Hinnant's public-domain algorithm).
/// (year, month 1–12, day 1–31) from days since 1970-01-01, exact for every `days`
/// (see [`crate::time::date_parts`]).
fn civil(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(yoe + era * 400 + i64::from(m <= 2), m, d)
use chrono::Datelike;
let (y, d) = crate::time::date_parts(days);
(y, d.month(), d.day())
}
/// Days since 1970-01-01 of a civil date (inverse of [`civil`]).
/// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year.
///
/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year within
/// its cycle and whole cycles are added back as days (an i128 so the extremes can't overflow),
/// which matches the proleptic Gregorian count the old hand-rolled code gave. 1970-01-01 is
/// `num_days_from_ce` 719_163.
///
/// A day past the end of its month carries into the next one, as before chrono (`2019-02-30`
/// is 2019-03-02: `parse_iso_date` accepts any day 1..=31): chrono places the 1st of the month
/// and the rest of the days are added on. Only a month outside 1..=12 reads as 0.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let mp = i64::from(if m > 2 { m - 3 } else { m + 9 });
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
i32::try_from(year)
.ok()
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, 1))
.and_then(|first| {
let days = i128::from(chrono::Datelike::num_days_from_ce(&first)) - 719_163 + cycles * 146_097 + i128::from(d) - 1;
i64::try_from(days).ok()
})
.unwrap_or(0)
}
/// Unix seconds of an ISO 8601 timestamp such as `2019-12-16T09:48:02+01:00` (seconds, the
@@ -294,6 +300,30 @@ mod tests {
assert_eq!(format_date(951_782_400, "yyyy-MM-dd"), "2000-02-29");
}
#[test]
fn dates_beyond_chrono_range() {
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; the clock can reach this,
// so keep the proleptic Gregorian date the old formula gave rather than the epoch, on both
// sides of the epoch. Values from the pre-chrono formula, through the public `format_date`.
assert_eq!(format_date(10_000_000_000_000, "yyyy-MM-dd HH:mm"), "318857-05-20 17:46");
assert_eq!(format_date(-8_334_659_289_600, "yyyy-MM-dd"), "-262145-03-01");
// `days_from_civil` is exact and inverts `civil` past the range, both sides.
for (y, m, d, days) in
[(262_144i64, 1u32, 1u32, 95_026_602i64), (300_000, 6, 15, 108_853_388), (-262_145, 3, 1, -96_465_964), (-300_000, 2, 29, -110_292_219)]
{
assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
assert_eq!(civil(days), (y, m, d), "{days}");
}
// The extremes must not overflow or panic (i128 intermediates).
let _ = (civil(i64::MAX), civil(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1));
// A day past the end of its month carries over, as before chrono, instead of reading as 1970.
assert_eq!(days_from_civil(2019, 2, 30), days_from_civil(2019, 3, 2));
assert_eq!(days_from_civil(2024, 2, 30), days_from_civil(2024, 3, 1));
assert_eq!(days_from_civil(2019, 4, 31), days_from_civil(2019, 5, 1));
assert_eq!(parse_iso_date("2019-02-30"), Some(1_551_484_800));
assert_eq!(parse_iso_date("2019-02-30T09:48:02Z"), parse_iso_date("2019-03-02T09:48:02Z"));
}
#[test]
fn iso_dates() {
assert_eq!(parse_iso_date("2026-10-01T14:05:00Z"), Some(1_790_863_500));
+1 -1
View File
@@ -436,7 +436,7 @@ fn assign_page(dst: &mut Document, abs: usize, from: &Page) {
p.guides = from.guides.clone();
p.parent = from.parent;
p.margins = from.margins;
p.columns = from.columns.clone();
p.columns = p.columns_from(from);
p.local_margins = from.local_margins;
p.width = from.width;
p.height = from.height;
+32 -21
View File
@@ -256,28 +256,39 @@ pub fn specs() -> Vec<CommandSpec> {
st.bump_revision();
ok()
}),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
cmd!(
"edit.clear",
"Clear",
["Edit"],
Some("Delete"),
"{ids?} — delete selected items (or the selected text or ruler guides)",
has_doc,
|s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}),
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
return s.execute("guide.delete", &json!({}));
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}
),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| {
if let Some(text) = copy_text(s)? {
return Ok(json!({"text": text}));
+564 -27
View File
@@ -1,18 +1,28 @@
//! Ruler guides: add (dragged from the rulers), move, delete, list; Layout › Create Guides.
//! Ruler guides: add (dragged from the rulers), select, move, delete, list; Layout › Create
//! Guides.
//!
//! Guides are stored on their page in spread coordinates; a spread guide (dropped on the
//! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard.
//! Selected guides are [`Selection::guides`], addressed as (spread, page, index): the commands
//! that delete guides renumber it, and [`keep_selection_valid`] drops what another command made
//! stale.
use designcraft_doc::{Document, Guide, Orientation, SpreadRef};
use std::collections::{HashMap, HashSet};
use designcraft_doc::{Document, Guide, GuideSel, Orientation, Selection, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{DocState, Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
/// The farthest a guide may be from the spread origin, in points, either way.
const MAX_GUIDE_POSITION: f64 = 1_000_000.0;
/// The most guides one `guides` list may name.
const MAX_GUIDES_NAMED: usize = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -21,34 +31,96 @@ pub fn specs() -> Vec<CommandSpec> {
"New Guide",
[],
None,
"{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}",
"{orientation: horizontal|vertical, position (spread coordinate, at most 1000000 pt either way), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}. The new guides become the selection; objects are deselected",
has_doc,
add
),
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.move", "Move Guide", [], None, "{spread?, page, index, position (at most 1000000 pt either way)}", has_doc, move_guide),
cmd!(
"guide.moveBy",
"Move Guides",
[],
None,
"{dx?, dy?, guides?: [{spread?, page, index}] (default: the selected guides)} — vertical guides move by dx, horizontal ones by dy, as one undo step",
has_doc,
move_by
),
cmd!(
"guide.delete",
"Delete Guide",
[],
None,
"{spread?, page, index}, or {} for the selected guides; an address without page or index is an error",
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")?;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
for pg in &mut sp.pages {
n += pg.guides.len();
pg.guides.clear();
}
sel.guides.retain(|g| g.spread != r);
Ok(json!({"deleted": n}))
})
}),
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 mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread}));
cmd!(
"view.lockColumnGuides",
"Lock Column Guides",
["View", "Grids & Guides"],
None,
"{on?: bool (default: toggle)} → {locked} — locked column guides can't be dragged; saved with the document (IDML ColumnGuideLocked), on in new documents",
has_doc,
lock_column_guides
),
cmd!(
query "guide.list",
"Guides",
[],
None,
"{spread?} → [{page, index, orientation, position, spread, selected}]",
has_doc,
|s, p| {
let st = s.doc()?;
let r = super::spread_param(p, "guide.list")?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.list", "no such spread"))?;
let picked: HashSet<GuideSel> = st.selection.guides.iter().copied().collect();
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
let selected = picked.contains(&GuideSel { spread: r, page: pi, index: i });
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread, "selected": selected}));
}
}
Ok(Value::Array(out))
}
Ok(Value::Array(out))
),
cmd!(
noundo "guide.select",
"Select Guide",
[],
None,
"{spread?, page, index, guides?: [{spread?, page, index}] (several, in place of page and index), add?: bool (keep the guides already selected), toggle?: bool (deselect a selected guide instead: Shift-click)} — selects ruler guides and deselects objects → {selected: n}",
has_doc,
select
),
cmd!(
noundo "guide.selectAll",
"Select All Guides",
["Edit"],
Some("Cmd+Alt+G"),
"{spread?} — every guide on the spread that is not locked or on a locked or hidden layer → {selected: n}",
has_doc,
select_all
),
cmd!(noundo "guide.deselect", "Deselect Guides", [], None, "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
st.selection.guides.clear();
st.bump_revision();
super::ok()
}),
cmd!(
"layout.createGuides",
@@ -62,6 +134,18 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn lock_column_guides(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.settings.column_guides_locked;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
if on == cur {
return Ok(json!({"locked": cur}));
}
s.edit(|d, _| {
d.settings.column_guides_locked = on;
Ok(json!({"locked": on}))
})
}
fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> {
let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?;
usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform")))
@@ -80,6 +164,14 @@ fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
Ok(Some(number))
}
/// `position` as a guide position: finite and within [`MAX_GUIDE_POSITION`] of the origin.
fn guide_position(position: f64, command: &str) -> Result<f64> {
if !position.is_finite() || position.abs() > MAX_GUIDE_POSITION {
return Err(bad(command, format!("a guide position must be between -{MAX_GUIDE_POSITION} and {MAX_GUIDE_POSITION}")));
}
Ok(position)
}
fn orientation(p: &Value) -> Result<Orientation> {
match str_param(p, "orientation") {
Some("horizontal") => Ok(Orientation::Horizontal),
@@ -115,6 +207,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
vec![(o, pos, x)]
};
for (_, pos, _) in &guides {
guide_position(*pos, "guide.add")?;
}
let d = &s.doc()?.doc;
let placed: Vec<(Orientation, f64, usize)> = guides
.into_iter()
@@ -128,11 +223,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?;
let mut out = Vec::new();
let mut added = Vec::new();
for (o, pos, pi) in placed {
let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?;
let index = pg.guides.len();
pg.guides.push(Guide {
orientation: o,
position: pos,
@@ -141,8 +238,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
layer: Some(layer),
liquid: liquid && !spread_guide,
});
out.push(json!({"page": pi, "index": pg.guides.len() - 1}));
out.push(json!({"page": pi, "index": index}));
added.push(GuideSel { spread: r, page: pi, index });
}
// A new guide is selected, as in InDesign; nothing else stays selected.
*sel = Selection { guides: added, ..Default::default() };
match <[Value; 1]>::try_from(out) {
Ok([one]) => Ok(one),
Err(out) => Ok(json!({"guides": out})),
@@ -168,6 +268,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?;
let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?;
let pos = guide_position(pos, "guide.move")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d
@@ -181,18 +282,209 @@ fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.delete")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.delete")?;
let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?;
if i >= pg.guides.len() {
return Err(bad("guide.delete", "no such guide"));
// An address names one guide: a partial one (only `spread`, say) is an error, not the selection.
let targets = if ["spread", "page", "index"].iter().all(|k| p.get(k).is_none()) {
s.doc()?.selection.guides.clone()
} else {
let (spread, page, index) = guide_ref(p, "guide.delete")?;
vec![GuideSel { spread, page, index }]
};
if targets.is_empty() {
return Err(bad("guide.delete", "no guide selected"));
}
s.edit(|d, sel| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.delete")?;
}
pg.guides.remove(i);
Ok(Value::Null)
let deleted = remove_guides(d, sel, &targets, "guide.delete")?;
Ok(json!({"deleted": deleted}))
})
}
/// Removes `targets` (existing guides; repeats count once) and renumbers the guide selection to
/// match. Returns how many guides it removed.
fn remove_guides(d: &mut Document, sel: &mut Selection, targets: &[GuideSel], command: &str) -> Result<usize> {
let gone: HashSet<GuideSel> = targets.iter().copied().collect();
// Per page, the removed indices in ascending order.
let mut by_page: HashMap<(SpreadRef, usize), Vec<usize>> = HashMap::new();
for g in &gone {
by_page.entry((g.spread, g.page)).or_default().push(g.index);
}
for ((spread, page), indices) in &mut by_page {
indices.sort_unstable();
let pg = d.spread_mut(*spread).and_then(|sp| sp.pages.get_mut(*page)).ok_or_else(|| bad(command, "no such page"))?;
for &i in indices.iter().rev() {
if i >= pg.guides.len() {
return Err(bad(command, "no such guide"));
}
pg.guides.remove(i);
}
}
sel.guides = sel
.guides
.iter()
.filter(|g| !gone.contains(g))
.map(|g| {
let below = by_page.get(&(g.spread, g.page)).map_or(0, |indices| indices.partition_point(|&i| i < g.index));
GuideSel { index: g.index.saturating_sub(below), ..*g }
})
.collect();
Ok(gone.len())
}
/// The guides a `guides: [{spread?, page, index}]` list names, if the params have one.
fn guides_param(p: &Value, command: &str) -> Result<Option<Vec<GuideSel>>> {
let Some(list) = p.get("guides") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(command, "`guides` must be a list of {spread?, page, index}"))?;
if list.len() > MAX_GUIDES_NAMED {
return Err(bad(command, format!("`guides` may name at most {MAX_GUIDES_NAMED} guides")));
}
list.iter()
.map(|g| {
let (spread, page, index) = guide_ref(g, command)?;
Ok(GuideSel { spread, page, index })
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
/// The guide `g` addresses, if there is one.
fn guide_of(d: &Document, g: GuideSel) -> Option<&Guide> {
d.spread(g.spread)?.pages.get(g.page)?.guides.get(g.index)
}
/// A guide that can be selected: it exists, and neither it nor its layer is locked or hidden.
pub(crate) fn selectable(d: &Document, g: GuideSel) -> bool {
guide_of(d, g).is_some_and(|guide| guide.editable_in(d))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let named = match guides_param(p, "guide.select")? {
Some(list) => list,
None => {
let (spread, page, index) = guide_ref(p, "guide.select")?;
vec![GuideSel { spread, page, index }]
}
};
let (add, toggle) = (bool_or(p, "add", false), bool_or(p, "toggle", false));
let st = s.doc_mut()?;
for g in &named {
check_editable(&st.doc, g.spread, g.page, g.index, "guide.select")?;
}
let mut guides = if add || toggle { st.selection.guides.clone() } else { Vec::new() };
let mut have: HashSet<GuideSel> = guides.iter().copied().collect();
let mut seen = HashSet::new();
let mut dropped = HashSet::new();
for g in named.into_iter().filter(|g| seen.insert(*g)) {
if !have.insert(g) {
if toggle {
dropped.insert(g);
}
} else {
guides.push(g);
}
}
guides.retain(|g| !dropped.contains(g));
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let r = super::spread_param(p, "guide.selectAll")?;
let st = s.doc_mut()?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.selectAll", "no such spread"))?;
let guides: Vec<GuideSel> = sp
.pages
.iter()
.enumerate()
.flat_map(|(page, pg)| (0..pg.guides.len()).map(move |index| GuideSel { spread: r, page, index }))
.filter(|g| selectable(&st.doc, *g))
.collect();
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let dx = finite_number(p, "dx", "guide.moveBy")?.unwrap_or(0.0);
let dy = finite_number(p, "dy", "guide.moveBy")?.unwrap_or(0.0);
let mut targets = match guides_param(p, "guide.moveBy")? {
Some(list) => list,
None => s.doc()?.selection.guides.clone(),
};
let mut seen = HashSet::new();
targets.retain(|g| seen.insert(*g));
if targets.is_empty() {
return Err(bad("guide.moveBy", "no guide selected"));
}
s.edit(|d, _| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.moveBy")?;
}
for g in &targets {
let guide = d
.spread_mut(g.spread)
.and_then(|sp| sp.pages.get_mut(g.page))
.and_then(|pg| pg.guides.get_mut(g.index))
.ok_or_else(|| bad("guide.moveBy", "no such guide"))?;
let to = guide.position
+ match guide.orientation {
Orientation::Vertical => dx,
Orientation::Horizontal => dy,
};
guide.position = guide_position(to, "guide.moveBy").map_err(|_| bad("guide.moveBy", "the guide would move out of range"))?;
}
Ok(json!({"moved": targets.len()}))
})
}
/// After a command: guides stay selected only while nothing else is (objects, text, cells, a
/// page), and only those that are still there and can be selected. A command that changed
/// which guides a page holds without keeping the guide selection up to date clears it, since
/// its (page, index) addresses may name other guides now. `before` is the document and
/// selection before the command, when it ran on the same document.
pub(crate) fn keep_selection_valid(st: &mut DocState, before: Option<(&Document, &Selection)>, id: &str) {
let sel = &mut st.selection;
if sel.guides.is_empty() {
return;
}
if !sel.items.is_empty() || sel.text.is_some() || sel.cells.is_some() || sel.page.is_some() {
sel.guides.clear();
return;
}
let kept_up = id.starts_with("guide.") || matches!(id, "edit.undo" | "edit.redo") || before.is_some_and(|(_, old)| old.guides != sel.guides);
if !kept_up
&& let Some((old, _)) = before
&& !std::ptr::eq(old, st.doc.as_ref())
&& !same_guide_addresses(old, &st.doc)
{
sel.guides.clear();
return;
}
let mut seen = HashSet::new();
sel.guides.retain(|g| selectable(&st.doc, *g) && seen.insert(*g));
}
/// Every (spread, page, index) of `old` still names a guide of the same orientation on the same
/// page in `new`: guides were only added at the end of their pages, or moved.
fn same_guide_addresses(old: &Document, new: &Document) -> bool {
let same = |a: &[std::sync::Arc<designcraft_doc::Spread>], b: &[std::sync::Arc<designcraft_doc::Spread>]| {
a.len() == b.len()
&& a.iter().zip(b).all(|(a, b)| {
a.pages.len() == b.pages.len()
&& a.pages.iter().zip(&b.pages).all(|(a, b)| {
a.id == b.id
&& a.guides.len() <= b.guides.len()
&& a.guides.iter().zip(&b.guides).all(|(a, b)| a.orientation == b.orientation)
})
})
};
same(&old.spreads, &new.spreads) && same(&old.parents, &new.parents)
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(0) };
let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?;
@@ -273,7 +565,11 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|count| *count <= MAX_GUIDES_CREATED)
.ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?;
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
if remove {
// The cleared pages' guide addresses name the new guides now.
sel.guides.retain(|g| g.spread != r || only.is_some_and(|o| o != g.page));
}
let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
let mut n = 0usize;
for (pi, pg) in sp.pages.iter_mut().enumerate() {
@@ -315,6 +611,39 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
#[test]
fn lock_column_guides_toggles_sets_and_undoes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let locked = |s: &Session| s.doc().unwrap().doc.settings.column_guides_locked;
let undo_steps = |s: &Session| s.doc().unwrap().history.undo.len();
assert!(locked(&s), "locked in new documents");
let before = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
assert!(!locked(&s));
assert_eq!(undo_steps(&s), before + 1, "one undo step per toggle");
s.execute("edit.undo", &json!({})).unwrap();
assert!(locked(&s), "undo restores the lock");
s.execute("edit.redo", &json!({})).unwrap();
assert!(!locked(&s), "redo unlocks again");
// Setting the current value changes nothing and adds no undo step.
let steps = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap()["locked"], false);
assert_eq!(undo_steps(&s), steps);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap()["locked"], true);
assert!(locked(&s));
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
// Saved with the document; files without the setting read as locked.
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
assert!(json.contains("\"columnGuidesLocked\":false"), "{json}");
let back: Document = serde_json::from_str(&json).unwrap();
assert!(!back.settings.column_guides_locked);
let old = json.replace("\"columnGuidesLocked\":false,", "").replace(",\"columnGuidesLocked\":false", "");
assert!(!old.contains("columnGuidesLocked"));
let back: Document = serde_json::from_str(&old).unwrap();
assert!(back.settings.column_guides_locked);
}
#[test]
fn guides_add_move_delete_and_grid() {
let mut s = Session::new();
@@ -390,6 +719,214 @@ mod tests {
}
}
#[cfg(test)]
mod selection_tests {
use serde_json::{Value, json};
use crate::Session;
/// A page with vertical guides at 100, 200, 300 and a horizontal one at 400.
fn guides() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for x in [100.0, 200.0, 300.0] {
s.execute("guide.add", &json!({"orientation": "vertical", "position": x})).unwrap();
}
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 400.0})).unwrap();
s
}
fn selected(s: &Session) -> Vec<usize> {
s.doc().unwrap().selection.guides.iter().map(|g| g.index).collect()
}
fn positions(s: &mut Session) -> Vec<f64> {
let l = s.execute("guide.list", &json!({})).unwrap();
l.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect()
}
#[test]
fn selecting_guides_clears_objects_and_the_other_way_round() {
let mut s = guides();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 1})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
assert_eq!(selected(&s), [1]);
// Shift-click adds, Shift-click again removes.
s.execute("guide.select", &json!({"page": 0, "index": 3, "toggle": true})).unwrap();
assert_eq!(selected(&s), [1, 3]);
s.execute("guide.select", &json!({"page": 0, "index": 1, "toggle": true})).unwrap();
assert_eq!(selected(&s), [3]);
let r = s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}], "add": true})).unwrap();
assert_eq!(r["selected"], 3);
let l = s.execute("guide.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().iter().map(|g| g["selected"] == true).collect::<Vec<_>>(), [true, false, true, true]);
// Selecting an object (Shift-click too) deselects the guides.
s.execute("selection.toggle", &json!({"id": id})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deselect", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// No such guide.
assert!(s.execute("guide.select", &json!({"page": 0, "index": 9})).is_err());
assert!(s.execute("guide.select", &json!({"page": 3, "index": 0})).is_err());
assert!(s.execute("guide.select", &json!({"guides": "all"})).is_err());
}
#[test]
fn select_all_guides_skips_locked_ones() {
let mut s = guides();
let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": layer})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 500.0})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(s.execute("guide.selectAll", &json!({})).unwrap()["selected"], 4);
assert_eq!(selected(&s), [0, 1, 2, 3]);
assert!(s.execute("guide.select", &json!({"page": 0, "index": 4})).is_err());
// Locking the layer of a selected guide deselects it.
s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(selected(&s), [0, 1, 2, 3]);
let spec = crate::cmd::find_command("guide.selectAll").unwrap();
assert_eq!((spec.menu, spec.shortcut), (&["Edit"][..], Some("Cmd+Alt+G")));
}
#[test]
fn selected_guides_move_and_delete_as_one_undo_step() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Vertical guides move by dx, horizontal ones by dy.
s.execute("guide.moveBy", &json!({"dx": 10.0, "dy": -5.0})).unwrap();
assert_eq!(positions(&mut s), [110.0, 200.0, 310.0, 395.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
// Delete (edit.clear, as the Delete key) takes all three; the guide left is the one at 200.
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 3);
assert_eq!(positions(&mut s), [200.0]);
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [200.0]);
assert!(s.execute("guide.moveBy", &json!({"dx": 1.0})).is_err(), "nothing selected");
// A guide stays within 1000000 pt of the origin.
assert!(s.execute("guide.moveBy", &json!({"dx": f64::MAX, "guides": [{"page": 0, "index": 0}]})).is_err());
assert!(s.execute("guide.moveBy", &json!({"dx": 1e6, "guides": [{"page": 0, "index": 0}]})).is_err());
assert_eq!(positions(&mut s), [200.0]);
s.execute("guide.moveBy", &json!({"dx": -1e6 - 200.0, "guides": [{"page": 0, "index": 0}]})).unwrap();
assert_eq!(positions(&mut s), [-1e6]);
assert!(s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 1e6 + 1.0})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": -2e6})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "both", "point": [0.0, 1e300]})).is_err());
assert_eq!(positions(&mut s), [-1e6]);
}
#[test]
fn the_guide_selection_follows_deletes_and_drops_stale_guides() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Deleting a guide before them renumbers them.
s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap();
assert_eq!(selected(&s), [1, 2]);
assert_eq!(positions(&mut s), [200.0, 300.0, 400.0]);
// Deleting a selected one deselects it.
s.execute("guide.delete", &json!({"page": 0, "index": 1})).unwrap();
assert_eq!(selected(&s), [1]);
// Undo puts back the selection that went with the document.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [1, 2]);
// A new guide is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 50.0})).unwrap();
assert_eq!(selected(&s), [3]);
// Undoing the add to before the first delete: those addresses held other guides then.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [2, 3]);
// Create Guides replacing the page's guides: their addresses name new guides, so the
// selection goes.
s.execute("layout.createGuides", &json!({"columns": 2, "removeExisting": true})).unwrap();
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": 0})).unwrap();
assert_eq!(selected(&s), [0], "guides only added: the addresses still hold");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deleteAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// Deleting the page goes with it.
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap();
s.execute("guide.selectAll", &json!({"spread": 1})).unwrap();
assert_eq!(selected(&s), [0]);
s.execute("layout.pages.delete", &json!({"pages": [1]})).unwrap();
assert!(selected(&s).is_empty());
let _: Value = s.execute("guide.list", &json!({})).unwrap();
}
}
#[cfg(test)]
mod new_guide_tests {
use designcraft_doc::GuideSel;
use serde_json::json;
use crate::Session;
fn selected(s: &Session) -> Vec<GuideSel> {
s.doc().unwrap().selection.guides.clone()
}
#[test]
fn a_new_guide_is_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100.0})).unwrap();
let spread = s.doc().unwrap().selection.guides[0].spread;
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
assert!(s.doc().unwrap().selection.items.is_empty(), "objects are deselected");
// Both ways (Cmd-drag from the ruler corner): the two new guides.
s.execute("guide.add", &json!({"orientation": "both", "point": [50.0, 60.0], "spreadGuide": true})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 1 }, GuideSel { spread, page: 0, index: 2 }]);
// Undo gives back the selection from before.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
}
#[test]
fn delete_takes_a_whole_address_or_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
for spread in [0, 1] {
s.execute("guide.add", &json!({"spread": spread, "orientation": "vertical", "position": 100.0})).unwrap();
}
s.execute("guide.select", &json!({"spread": 1, "page": 0, "index": 0})).unwrap();
// A spread without page and index is not "the selection".
assert!(s.execute("guide.delete", &json!({"spread": 0})).is_err());
assert!(s.execute("guide.delete", &json!({"spread": 1, "page": 0})).is_err());
assert_eq!(selected(&s).len(), 1);
// A selection naming a guide twice, apart, deletes it once.
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 200.0})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 300.0})).unwrap();
let spread = selected(&s)[0].spread;
let at = |index| GuideSel { spread, page: 0, index };
s.doc_mut().unwrap().selection.guides = vec![at(0), at(2), at(0)];
assert_eq!(s.execute("guide.delete", &json!({})).unwrap()["deleted"], 2);
let left = s.execute("guide.list", &json!({"spread": 1})).unwrap();
assert_eq!(left.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect::<Vec<_>>(), [200.0]);
assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap().as_array().unwrap().len(), 1);
}
}
#[cfg(test)]
mod layer_tests {
use serde_json::json;
+3 -1
View File
@@ -85,7 +85,9 @@ fn inspect(s: &mut Session, _p: &Value) -> Result<Value> {
let abs = d.first_page_of_spread(si) + pi;
json!({"index": abs, "name": d.page_name(abs), "bounds": [p.x, 0.0, p.x + p.width, p.height], "side": p.side,
"parent": p.parent.and_then(|pid| d.parents.iter().find(|x| x.id == pid)).and_then(|x| x.parent.as_ref()).map(|i| i.label()),
"margins": p.margins, "columns": p.columns.count})
"margins": p.margins, "columns": p.columns.count, "gutter": p.columns.gutter,
"customColumns": p.columns.valid_positions(p.margin_rect().width()).is_some(),
"columnRects": p.column_rects().iter().map(|r| [r.x0, r.y0, r.x1, r.y1]).collect::<Vec<_>>()})
}).collect::<Vec<_>>(),
"items": sp.items.iter().map(|i| item_json(i)).collect::<Vec<_>>(),
})
+288 -9
View File
@@ -88,6 +88,7 @@ fn dist(r: designcraft_geom::Rect, x: f64) -> f64 {
/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page.
fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
let width_before = pg.margin_rect().width();
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
@@ -97,14 +98,62 @@ fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
}
_ => {}
}
scale_columns(pg, width_before);
// A new count or gutter re-divides the columns evenly: dragged column positions are what
// the new value replaces. The dialog always sends both, so an unchanged value keeps them.
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
let c = u32::try_from(c).unwrap_or(u32::MAX).clamp(1, 216);
if c != pg.columns.count {
pg.columns.count = c;
pg.columns.positions = None;
}
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
if let Some(g) = p.get("gutter").and_then(Value::as_f64).filter(|g| g.is_finite()) {
let g = g.max(0.0);
if (g - pg.columns.gutter).abs() > GUTTER_TOLERANCE {
pg.columns.gutter = g;
pg.columns.positions = None;
}
}
}
/// Dragged column positions are measured across the margin box. When the box was
/// `width_before` wide and now has another width, they scale with it, as InDesign's do; a set
/// that doesn't scale into a valid one goes back to the even split rather than stay hidden.
fn scale_columns(pg: &mut designcraft_doc::Page, width_before: f64) {
let width = pg.margin_rect().width();
if (width - width_before).abs() <= GUTTER_TOLERANCE {
return;
}
let Some(positions) = pg.columns.positions.as_mut() else { return };
let factor = width / width_before;
if factor.is_finite() && factor > 0.0 {
positions.iter_mut().for_each(|p| *p *= factor);
// The ends sit exactly on the margins whatever the rounding.
if let Some(first) = positions.first_mut() {
*first = 0.0;
}
if let Some(last) = positions.last_mut() {
*last = width;
}
}
if pg.columns.valid_positions(width).is_none() {
pg.columns.positions = None;
}
}
/// Gives `pg` the page width `width`, scaling its dragged columns with the margin box.
fn set_page_width(pg: &mut designcraft_doc::Page, width: f64) {
let before = pg.margin_rect().width();
pg.width = width;
scale_columns(pg, before);
}
/// How far a gutter may differ from the page's and still count as unchanged, in points. The
/// dialog shows the gutter in the ruler unit, rounded (inches to four decimals: up to 0.0036 pt
/// off), so an OK that leaves the field alone sends back a slightly different number.
const GUTTER_TOLERANCE: f64 = 0.01;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -197,8 +246,9 @@ pub fn specs() -> Vec<CommandSpec> {
let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]);
let pg = &mut nsp.pages[npi];
let dx = pg.x - src.x;
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) =
(src.width, src.height, src.margins, src.columns.clone(), src.liquid);
// A copy on the other side of the spine has its dragged columns mirrored.
let columns = pg.columns_from(src);
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid);
pg.guides = src.guides.clone();
for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
g.position += dx;
@@ -252,8 +302,20 @@ pub fn specs() -> Vec<CommandSpec> {
let name = str_param(p, "name").unwrap_or("Parent").to_string();
s.edit(|d, _| {
let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix());
let m = d.page(0).map(|p| (p.columns.count, p.columns.gutter, p.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
let id = d.add_parent(&prefix, &name, m.0, m.1, m.2);
let src = d.page(0).cloned();
let (columns, margins) =
src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins));
let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins);
// Dragged column guides carry over to the parent pages they still fit, mirrored
// on the parent page across the spine from the first page.
if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) {
for pg in &mut std::sync::Arc::make_mut(sp).pages {
let columns = pg.columns_from(src);
if columns.valid_positions(pg.margin_rect().width()).is_some() {
pg.columns.positions = columns.positions;
}
}
}
Ok(json!({"id": id.0, "prefix": prefix}))
})
}),
@@ -477,7 +539,7 @@ pub fn specs() -> Vec<CommandSpec> {
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
let sp = std::sync::Arc::make_mut(sp);
for pg in &mut sp.pages {
pg.width = d.settings.page_width;
set_page_width(pg, d.settings.page_width);
pg.height = d.settings.page_height;
}
sp.relayout();
@@ -957,7 +1019,7 @@ pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usiz
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height);
if let Some(w) = w {
pg.width = w.max(1.0);
set_page_width(pg, w.max(1.0));
}
if let Some(h) = h {
pg.height = h.max(1.0);
@@ -1650,6 +1712,191 @@ mod adjust_tests {
// An unknown parent is an error, not a silent no-op.
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err());
}
/// Margins and Columns re-divides the columns: a new count or gutter replaces column guides
/// that were dragged (an imported Custom columns page), rather than being ignored by them.
#[test]
fn a_new_column_count_replaces_dragged_column_positions() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
// There is no command for dragging a column guide yet, so set what an import would leave.
let drag = |s: &mut Session| {
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
Ok(())
})
.unwrap();
};
drag(&mut s);
let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert_eq!(dragged.len(), 2);
assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}");
// A new count: evenly divided again, and the stale positions are gone.
s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None));
assert_eq!(pg.column_rects().len(), 3);
// So does a new gutter on its own.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!(pg.columns.positions, None);
assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6);
// Margins that keep the margin box's width leave them alone; a new width scales them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"margins": {"top": 72, "bottom": 72, "inside": 36, "outside": 36}})).unwrap();
assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0]));
s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap();
assert_scaled(s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(), 468.0);
}
/// The params the Margins and Columns dialog sends on OK: every margin, the count and the
/// gutter, whether or not the user changed them.
fn dialog_params(margin: f64, columns: u32, gutter: f64) -> serde_json::Value {
json!({"margins": {"top": margin, "bottom": margin, "inside": margin, "outside": margin},
"columns": columns, "gutter": gutter, "adjustLayout": false})
}
const DRAGGED: [f64; 4] = [0.0, 100.0, 130.0, 540.0];
fn dragged() -> designcraft_doc::Columns {
designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(DRAGGED.to_vec()) }
}
/// `positions` are [`DRAGGED`] scaled from its 540 pt margin box to one `width` wide.
#[track_caller]
fn assert_scaled(positions: Option<Vec<f64>>, width: f64) {
let want: Vec<f64> = DRAGGED.iter().map(|p| p * width / 540.0).collect();
let got = positions.unwrap_or_default();
assert!(got.len() == want.len() && got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} ≠ {want:?}");
}
/// An OK in the dialog keeps dragged columns unless the count or gutter it sends differs.
#[test]
fn the_dialog_keeps_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].columns = dragged();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
// The same count and gutter (the gutter as an inch field rounds it) keep them in use.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 0.1667 * 72.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0).abs() < 1e-6, "the dragged columns are in use: {rects:?}");
// Top and bottom margins leave the margin box's width alone, and the columns with it.
let mut taller = dialog_params(36.0, 2, 12.0);
taller["margins"]["top"] = json!(72.0);
taller["margins"]["bottom"] = json!(72.0);
s.execute("layout.marginsAndColumns", &taller).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
// A new width scales them with the margin box, and the old width scales them back.
s.execute("layout.marginsAndColumns", &dialog_params(72.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 468.0);
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0 * 468.0 / 540.0).abs() < 1e-6, "the scaled columns are in use: {rects:?}");
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 540.0);
// A new count resets them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 3, 12.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions), (3, None));
// So does a new gutter.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 18.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.gutter, pg.columns.positions), (18.0, None));
}
/// A page of another width (Page Size, Document Setup) scales its dragged columns.
#[test]
fn a_new_page_width_scales_dragged_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
// A dragged page's columns are its own (`local_margins`), as the drag will make them.
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = dragged();
pg.local_margins = true;
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
drag(&mut s);
s.execute("layout.pageSize", &json!({"pages": [1], "height": 900})).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()), "a new height keeps them");
s.execute("layout.pageSize", &json!({"pages": [1], "width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
s.execute("layout.pageSize", &json!({"pages": [1], "width": 612})).unwrap();
assert_scaled(positions(&s), 540.0);
drag(&mut s);
s.execute("layout.documentSetup", &json!({"width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
}
/// Parent pages follow the same rule, and the document pages that follow them see it.
#[test]
fn the_dialog_keeps_a_parents_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged();
d.sync_parent_margins();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| {
let d = &s.doc().unwrap().doc;
(d.parents[0].pages[0].columns.positions.clone(), d.page(0).unwrap().columns.positions.clone())
};
let on_parent = |m: f64, c: u32, g: f64| {
let mut p = dialog_params(m, c, g);
p["parent"] = json!(0);
p
};
drag(&mut s);
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(48.0, 2, 12.0)).unwrap();
let (parent, follower) = positions(&s);
assert_scaled(parent, 516.0);
assert_scaled(follower, 516.0);
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 3, 12.0)).unwrap();
assert_eq!(positions(&s), (None, None));
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 24.0)).unwrap();
assert_eq!(positions(&s), (None, None));
}
}
#[cfg(test)]
@@ -1948,6 +2195,38 @@ mod parent_tests {
assert_eq!(r, json!({"on": false, "parent": null}));
assert!(!s.doc().unwrap().editing_parents);
}
/// A new parent takes the first page's columns, dragged column guides included: the right
/// parent page as the first (right) page has them, the left one mirrored across the spine.
/// `document.inspect` reports where those columns are.
#[test]
fn a_new_parent_keeps_dragged_columns() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let dragged = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
// There is no command for dragging a column guide yet, so set what an import would leave.
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
(pg.columns, pg.local_margins) = (dragged.clone(), true);
Ok(())
})
.unwrap();
let id = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let parent = d.parents.iter().find(|p| p.id.0 == id).unwrap();
assert_eq!(parent.pages.len(), 2);
let side = |s: designcraft_doc::PageSide| parent.pages.iter().find(|p| p.side == s).unwrap();
let (left, right) = (side(designcraft_doc::PageSide::Left), side(designcraft_doc::PageSide::Right));
assert_eq!(right.columns, dragged);
assert_eq!(right.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![100.0, 410.0]);
assert_eq!(left.columns.positions, Some(vec![0.0, 410.0, 440.0, 540.0]));
assert_eq!(left.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![410.0, 100.0]);
let inspect = s.execute("document.inspect", &json!({})).unwrap();
let page = &inspect["spreads"][0]["pages"][0];
assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}");
assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}");
}
}
#[cfg(test)]
+1 -1
View File
@@ -20,7 +20,7 @@ pub(crate) mod find;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
mod guides;
pub(crate) mod guides;
mod index;
mod inspect;
mod interactive;
+38 -1
View File
@@ -277,7 +277,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) };
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
new_pages.extend(d.insert_pages(Some(page), pages, parent)?);
@@ -511,6 +511,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Autoflow follows dragged column guides: one frame per dragged column, on the first page and
/// on every page it adds.
#[test]
fn autoflow_sets_frames_on_dragged_columns() {
let dir = std::env::temp_dir().join(format!("dc-place-dragged-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 40].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
// The margin box is 504 pt wide; the gutter is dragged left. There is no command for
// dragging a column guide yet, so set what an import would leave, on the parent.
let dragged = designcraft_doc::Columns { count: 2, gutter: 18.0, positions: Some(vec![0.0, 150.0, 170.0, 504.0]) };
s.edit(|d, _| {
for pg in &mut std::sync::Arc::make_mut(&mut d.parents[0]).pages {
pg.columns = dragged.clone();
}
d.sync_parent_margins();
Ok(())
})
.unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.page_count() >= 3, "{r}");
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
assert!(frames.len() > 2 * (d.page_count() - 1), "{} frames", frames.len());
for (i, f) in frames.iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, i / 2, "frame {i}");
let m = d.page(page).unwrap().margin_rect();
let (x0, x1) = [(0.0, 150.0), (170.0, 504.0)][i % 2];
assert_eq!(d.item(*f).unwrap().bounds(), Rect::new(m.x0 + x0, m.y0, m.x0 + x1, m.y1), "frame {i}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_keeps_the_frame_the_user_drew() {
let dir = std::env::temp_dir().join(format!("dc-place-drawn-{}", std::process::id()));
+11
View File
@@ -571,6 +571,7 @@ impl Session {
Ok(r) => r,
Err(e) => {
// What it did before it failed can be undone too.
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
return Err(e);
}
@@ -613,6 +614,7 @@ impl Session {
}
}
}
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
@@ -621,6 +623,15 @@ impl Session {
Ok(r)
}
/// Drops selected guides the command `id` made stale, against the document as it will be
/// recorded: after the passes that run with the command (smart reflow, live captions, autosize).
fn keep_guide_selection_valid(&mut self, before: Option<&(u64, Arc<Document>, Selection)>, id: &str) {
if let Some(st) = self.active_mut() {
let old = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, sel)| (d.as_ref(), sel));
cmd::guides::keep_selection_valid(st, old, id);
}
}
/// Record undo if the document changed (and we're not previewing an interaction), with the
/// selection from before the command. A command run by an undoable one leaves the step to it:
/// one command is one undo step.
+2 -1
View File
@@ -294,8 +294,9 @@ fn cmd_move_does_not_snap() {
fn cmd_resize_still_snaps_to_a_guide() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
// The new guide is selected: the frame created after it is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 250.0, "page": 0})).unwrap();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
+1
View File
@@ -12,3 +12,4 @@ workspace = true
designcraft-doc = { workspace = true }
designcraft-color = { workspace = true }
zip = { workspace = true }
chrono = { workspace = true }
+69 -73
View File
@@ -123,19 +123,13 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) {
}
/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`.
/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the
/// instant is moved into range by whole cycles and only the year is carried (matches the old
/// `civil_from_days`), rather than resetting to the epoch.
fn iso8601(t: i64) -> String {
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60)
let (y, dt) = designcraft_doc::time::utc_parts(t, 0);
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ"))
}
/// The package's `dcterms:modified`: the given time, else the document's last save or creation.
@@ -193,8 +187,6 @@ pub struct FixedPage {
/// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page.
pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let lang = language_tag(&opts.language);
let id = identifier(doc, opts, &title);
@@ -226,12 +218,41 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"page-list\" id=\"toc\"><h1>Pages</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
xml_text(&title)
);
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n",
xml_text(&id),
xml_text(&title)
let rendition = format!(
"\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>"
);
let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified };
let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine);
let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png")));
write_package(&opf, &nav, entries)
}
/// The package metadata both EPUB flavours share.
struct PackageHead<'a> {
lang: &'a str,
id: &'a str,
title: &'a str,
author: Option<&'a str>,
modified: &'a str,
}
/// The OPF package document. `prefix` goes on `<package>` (with its leading space), `meta` after
/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines.
fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String {
let PackageHead { lang, id, title, author, modified } = h;
let author = author.map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\"{prefix}>\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>{meta}\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n",
xml_text(id),
xml_text(title)
)
}
/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order
/// as (path, bytes, stored uncompressed).
fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator<Item = (String, &'a [u8], bool)>) -> Result<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
{
@@ -246,9 +267,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
z.start_file(name, deflate).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (name, data) in &files {
let o = if name.ends_with(".png") { stored } else { deflate };
z.start_file(name.as_str(), o).map_err(zerr)?;
for (name, data, store) in entries {
z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
z.finish().map_err(zerr)?;
@@ -1366,8 +1386,6 @@ fn base64(data: &[u8]) -> String {
/// Export the document as a reflowable EPUB 3.
pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let id = identifier(doc, opts, &title);
let modified = modified(doc, opts);
@@ -1428,56 +1446,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
);
}
let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" };
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}{spine}</spine>\n</package>\n",
xml_text(&id),
xml_text(&title)
);
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("mimetype", stored).map_err(zerr)?;
z.write_all(b"application/epub+zip").map_err(io)?;
for (name, data) in [
("META-INF/container.xml", container.as_bytes()),
("OEBPS/content.opf", opf.as_bytes()),
("OEBPS/nav.xhtml", nav.as_bytes()),
("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()),
] {
z.start_file(name, deflate).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (k, c) in chapters.iter().enumerate() {
z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?;
z.write_all(c.as_bytes()).map_err(io)?;
}
for (name, (_, data)) in &images {
z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (k, f) in opts.fonts.iter().enumerate() {
z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?;
z.write_all(&f.data).map_err(io)?;
}
if let Some(c) = &opts.cover {
z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?;
z.write_all(c).map_err(io)?;
let xhtml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"{}\" alt=\"{}\"/></body>\n</html>\n",
xml_text(&title),
cover_file(c).0,
xml_attr(&title)
);
z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?;
z.write_all(xhtml.as_bytes()).map_err(io)?;
}
z.finish().map_err(zerr)?;
let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified };
let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}"));
let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts);
let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)];
entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false)));
entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true)));
entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false)));
let cover_xhtml = opts.cover.as_ref().map(|c| {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"{}\" alt=\"{}\"/></body>\n</html>\n",
xml_text(&title),
cover_file(c).0,
xml_attr(&title)
)
});
if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) {
entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true));
entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false));
}
Ok(buf.into_inner())
write_package(&opf, &nav, entries)
}
#[cfg(test)]
@@ -1582,6 +1570,14 @@ mod tests {
(951_782_400, "2000-02-29T00:00:00Z"),
(1_791_891_000, "2026-10-13T11:30:00Z"),
(-86_400, "1969-12-31T00:00:00Z"),
// Years past 9999 keep the plain zero-padded year (no `+`).
(253_402_300_800, "10000-01-01T00:00:00Z"),
// Beyond chrono's range the old `civil_from_days` still converted; pin that output
// (never the epoch) in both directions and at the `i64` extremes.
(10_000_000_000_000, "318857-05-20T17:46:40Z"),
(-10_000_000_000_000, "-314918-08-13T06:13:20Z"),
(i64::MAX, "292277026596-12-04T15:30:07Z"),
(i64::MIN, "-292277022657-01-27T08:29:52Z"),
] {
assert_eq!(iso8601(t), want);
}
+5 -4
View File
@@ -8,7 +8,7 @@ use designcraft_doc::{
CharAttrs, CharFormat, ColumnsKind, Content, Document, Item, ListType, PageSide, ParaAttrs, ParaFormat, Rule, Shape, SpanColumns, Spread, Story,
TextFrameOptions, story as st,
};
use designcraft_geom::{Affine, PathData, Rect};
use designcraft_geom::{Affine, PathData};
use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, escape_id, num, pt};
use crate::xml::{El, Node, document, document_with_pi};
@@ -769,7 +769,7 @@ impl<'a> Ex<'a> {
},
)
.attr("CreatePrimaryTextFrame", bool_s(s.primary_text_frame))
.attr("ColumnGuideLocked", "true")
.attr("ColumnGuideLocked", bool_s(s.column_guides_locked))
.attr("MasterTextFrame", bool_s(s.primary_text_frame))
.attr("SnippetImportUsesOriginalLocation", "false"),
);
@@ -2901,8 +2901,9 @@ impl Group {
fn margin_el(pg: &designcraft_doc::Page) -> El {
let m = pg.margin_rect();
let cols = designcraft_doc::column_rects(Rect::new(0.0, 0.0, m.width(), m.height()), pg.columns.count.max(1), pg.columns.gutter);
let pos: Vec<String> = cols.iter().flat_map(|c| [num(c.x0), num(c.x1)]).collect();
// Dragged column guides that still fit the margin box are written as they stand, so they
// survive the round trip; otherwise the even split, which InDesign writes for unmoved columns.
let pos: Vec<String> = pg.columns.edges(m.width()).into_iter().map(num).collect();
El::new("MarginPreference")
.attr("ColumnCount", pg.columns.count.max(1))
.attr("ColumnGutter", num(pg.columns.gutter))
+86 -6
View File
@@ -850,6 +850,9 @@ impl<'r> Importer<'r> {
if let Some(v) = e.boolean("CreatePrimaryTextFrame") {
s.primary_text_frame = v;
}
if let Some(v) = e.boolean("ColumnGuideLocked") {
s.column_guides_locked = v;
}
}
fn grid_prefs(&mut self, e: &El) {
@@ -2534,9 +2537,15 @@ impl<'r> Importer<'r> {
.map(|q| (q.margins, q.columns.clone()))
};
let (margins, columns) = match (p.find("MarginPreference"), &from_parent) {
(Some(m), _) => margins_of(m),
(Some(m), _) => {
let (margins, columns) = margins_of(m);
(margins, fit_columns(columns, &margins, r.width()))
}
(None, Some(own)) => own.clone(),
(None, None) => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())),
(None, None) => {
let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default()));
(margins, fit_columns(columns, &margins, r.width()))
}
};
let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns);
out_pages.push(Page {
@@ -3465,10 +3474,39 @@ fn composer_in(name: &str) -> designcraft_doc::Composer {
fn margins_of(m: &El) -> (Margins, Columns) {
let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d);
(
Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) },
Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
)
let margins = Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) };
let count = g("ColumnCount", 1.0).clamp(1.0, f64::from(designcraft_doc::MAX_COLUMNS)) as u32;
let gutter = g("ColumnGutter", 12.0);
let positions = m.get("ColumnsPositions").and_then(|s| custom_columns(s, count, gutter));
(margins, Columns { count, gutter, positions })
}
/// `ColumnsPositions` when the column guides have been dragged off the even split: `2 × count`
/// offsets from the margin box's left edge, valid as [`designcraft_doc::valid_column_positions`]
/// over the box they span. InDesign writes the attribute whether or not they have been moved, so
/// an even set is dropped: `positions` means "custom", and keeping an even copy would freeze the
/// columns against a later change of count or gutter. Anything malformed is the even split; at
/// most one number past `2 × count` is parsed, so a long list costs nothing.
fn custom_columns(s: &str, count: u32, gutter: f64) -> Option<Vec<f64>> {
let want = 2 * count as usize;
let pos = s.split_whitespace().take(want + 1).map(|v| v.parse::<f64>().ok()).collect::<Option<Vec<f64>>>()?;
let width = *pos.last()?;
if !designcraft_doc::valid_column_positions(&pos, count, width) {
return None;
}
let even = Columns { count, gutter, positions: None }.edges(width);
// The tolerance only absorbs float noise in InDesign's arithmetic; a dragged guide moves far more.
pos.iter().zip(&even).any(|(a, b)| (a - b).abs() > 1e-4).then_some(pos)
}
/// `columns` with custom positions only when they fit the margin box of a `page_width`-wide page
/// with these margins (the box's width doesn't depend on which side of the spine it is).
fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Columns {
let width = (page_width - margins.inside - margins.outside).max(0.0);
if columns.valid_positions(width).is_none() {
columns.positions = None;
}
columns
}
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
@@ -3995,6 +4033,48 @@ mod page_column_import_safety_tests {
assert_eq!(margins, Margins::uniform(36.));
}
}
/// Hostile `ColumnsPositions` are the even split: never half-read, never a panic, and a long
/// list is not parsed past what `ColumnCount` allows (#1096).
#[test]
fn hostile_column_positions_fall_back_to_the_even_split() {
let positions = |count: &str, raw: &str| {
let m = El::new("MarginPreference").attr("ColumnCount", count).attr("ColumnGutter", "20").attr("ColumnsPositions", raw);
margins_of(&m).1.positions
};
assert_eq!(positions("2", "0 150 170 450"), Some(vec![0., 150., 170., 450.]));
let huge = (0..10_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" ");
for (count, raw) in [
("2", "0 215 235 450"), // the even split
("2", "0 150 170"), // odd
("2", "0 450"), // too few for the count
("2", "0 100 120 200 220 450"), // too many for the count
("2", "0 150 90 450"), // descending
("2", "0 NaN 170 450"),
("2", "0 inf 170 450"),
("2", "0 150 170 1e309"),
("2", "0 x 170 450"),
("2", "10 150 170 450"), // not from the margin
("2", ""),
("2", huge.as_str()),
("4000000000", huge.as_str()), // the count is capped at 216, so is the list
("216", huge.as_str()),
("NaN", "0 150 170 450"),
] {
assert_eq!(positions(count, raw), None, "{count}: {}", raw.get(..raw.len().min(40)).unwrap_or(""));
}
// The cap itself: 216 one-point columns with no gutter are the even split; one edge moved
// makes them custom.
let gapless = |raw: &str| {
let m = El::new("MarginPreference").attr("ColumnCount", "216").attr("ColumnGutter", "0").attr("ColumnsPositions", raw);
margins_of(&m).1.positions
};
let edges = |first_right: &str| {
(0..432).map(|i| if i == 1 { first_right.to_string() } else { ((i + 1) / 2).to_string() }).collect::<Vec<_>>().join(" ")
};
assert_eq!(gapless(&edges("1")), None);
assert_eq!(gapless(&edges("0.5")).map(|p| p.len()), Some(432));
}
}
#[cfg(test)]
+2 -1
View File
@@ -17,7 +17,8 @@
//! ## Supported subset
//! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph,
//! character and object styles (with `BasedOn` and style groups), fonts, document preferences
//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections,
//! (page size, facing pages, bleed/slug, margins and columns including dragged column positions,
//! units, grids), ruler guides, layers, sections,
//! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`,
//! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text
//! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow,
+134
View File
@@ -2773,6 +2773,18 @@ fn round_trips_the_zero_point_and_ruler_origin() {
assert_eq!(fixture("NaN 4").settings.zero_point, [0.0, 0.0]);
}
#[test]
fn round_trips_lock_column_guides() {
let mut d = Document::new(&NewDocument::default());
assert!(zip_text(&export_idml(&d), "Resources/Preferences.xml").contains(r#"ColumnGuideLocked="true""#));
assert!(import_idml(&export_idml(&d)).unwrap().settings.column_guides_locked);
d.settings.column_guides_locked = false;
let bytes = export_idml(&d);
let prefs = zip_text(&bytes, "Resources/Preferences.xml");
assert!(prefs.contains(r#"ColumnGuideLocked="false""#), "{prefs}");
assert!(!import_idml(&bytes).unwrap().settings.column_guides_locked);
}
#[test]
fn kenten_shatai_and_grid_settings_import_and_round_trip() {
use designcraft_doc::cjk::CharacterAlignment;
@@ -4194,6 +4206,128 @@ mod liquid_page_rules {
}
}
/// A dragged column gutter (InDesign's Custom columns) survives an IDML import, and an even set —
/// which InDesign writes for every page, moved or not — does not pretend to be custom (#1096).
#[test]
fn custom_column_positions_import_and_round_trip() {
// Page 1 of SPREAD is 500 wide with Left=20 Right=30, so its margin box is 450 wide and
// starts at x=30. Two columns with gutter 20 split evenly as "0 215 235 450".
let with = |cols: &str| {
let spread = SPREAD.replace(
r#"<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>"#,
&format!(
r#"<MarginPreference ColumnCount="2" ColumnGutter="20" Top="10" Bottom="10" Left="20" Right="30" ColumnDirection="Horizontal" {cols}/>"#
),
);
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()
};
let rects = |d: &designcraft_doc::Document| d.spreads[0].pages[0].column_rects().iter().map(|r| (r.x0, r.x1)).collect::<Vec<_>>();
// Dragged: the gutter sits at 150..170 instead of 215..235, and the columns follow it.
let d = with(r#"ColumnsPositions="0 150 170 450""#);
assert_eq!(d.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0]));
assert_eq!(rects(&d), vec![(30.0, 180.0), (200.0, 480.0)]);
// The even set is dropped, so a later change of count or gutter is not frozen out. The
// geometry is the same either way, which is what makes keeping it pointless.
let even = with(r#"ColumnsPositions="0 215 235 450""#);
assert_eq!(even.spreads[0].pages[0].columns.positions, None);
assert_eq!(rects(&even), vec![(30.0, 245.0), (265.0, 480.0)]);
// Including when InDesign's own arithmetic leaves the last bits different.
assert_eq!(with(r#"ColumnsPositions="0 215.00000000000003 234.99999999999997 450""#).spreads[0].pages[0].columns.positions, None);
// Malformed sets are ignored rather than half-read: an odd count, three columns' worth for a
// count of two, a descending pair, a NaN, a set that stops short of the page's margin box, and
// a list far longer than any count allows. Each page keeps the even split.
let huge = format!(r#"ColumnsPositions="{}""#, (0..10_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" "));
for bad in [
r#"ColumnsPositions="0 150 170""#,
r#"ColumnsPositions="0 100 120 200 220 450""#,
r#"ColumnsPositions="0 150 90 450""#,
r#"ColumnsPositions="0 x 170 450""#,
r#"ColumnsPositions="0 NaN 170 450""#,
r#"ColumnsPositions="0 150 170 400""#,
r#"ColumnsPositions="""#,
huge.as_str(),
] {
let d = with(bad);
assert_eq!(d.spreads[0].pages[0].columns.positions, None, "{}", bad.get(..bad.len().min(60)).unwrap_or(""));
assert_eq!(rects(&d), rects(&even));
}
// And they come back from an export, so the round trip keeps the dragged gutter.
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(back.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0]));
assert_eq!(rects(&back), rects(&d));
// A page nobody dragged still exports the even split, not a frozen copy of it.
assert_eq!(import_idml(&export_idml(&even)).unwrap().spreads[0].pages[0].columns.positions, None);
}
/// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes
/// it (a `<Page>` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex`
/// and `GuideZone`), in a document without facing pages. A spread guide sits beside it.
#[test]
fn ruler_guide_in_a_column_gutter_imports_without_facing_pages() {
use designcraft_doc::Orientation::{Horizontal, Vertical};
let package = |view: &str| {
let prefs =
PREFS.replace(r#"FacingPages="true""#, r#"FacingPages="false""#).replace("</idPkg:Preferences>", &format!("{view}</idPkg:Preferences>"));
let spread = SPREAD.replace(
r#"<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350">
<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>
</Page>"#,
r#"<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350" Name="1" OverrideList="">
<Properties><PageColor type="enumeration">UseMasterColor</PageColor></Properties>
<MarginPreference ColumnCount="2" ColumnGutter="20" Top="10" Bottom="10" Left="20" Right="30" ColumnDirection="Horizontal" ColumnsPositions="0 150 170 450"/>
<GridDataInformation FontStyle="Regular" PointSize="12"/>
<Guide Self="g1" OverriddenPageItemProps="" Orientation="Vertical" Location="180" FitToPage="true" ViewThreshold="0" Locked="false" ItemLayer="L1" PageIndex="0" GuideType="Ruler" GuideZone="0">
<Properties><GuideColor type="enumeration">Cyan</GuideColor></Properties>
</Guide>
<Guide Self="g2" OverriddenPageItemProps="" Orientation="Horizontal" Location="300" FitToPage="false" ViewThreshold="5" Locked="false" ItemLayer="L1" PageIndex="0" GuideType="Ruler" GuideZone="1">
<Properties><GuideColor type="enumeration">Cyan</GuideColor></Properties>
</Guide>
</Page>"#,
);
assert_ne!(spread, SPREAD, "the fixture's first page was replaced");
import_idml(&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),
]))
.unwrap()
};
let guides =
|d: &Document| d.spreads[0].pages[0].guides.iter().map(|g| (g.orientation, g.position, g.spread, g.layer.is_some())).collect::<Vec<_>>();
// The first page starts at x 0 in our spread coordinates, so the spread's and the page's ruler
// origins agree; no ViewPreference means InDesign's default, the spread.
for view in ["", r#"<ViewPreference RulerOrigin="SpreadOrigin"/>"#, r#"<ViewPreference RulerOrigin="PageOrigin"/>"#] {
let first = package(view);
assert!(!first.settings.facing_pages);
assert_eq!(guides(&first), [(Vertical, 180.0, false, true), (Horizontal, 300.0, true, true)], "{view:?}");
// The guide sits between the page's left margin (20) and right margin (470).
let m = first.spreads[0].pages[0].margin_rect();
assert!(m.x0 < 180.0 && 180.0 < m.x1);
let back = import_idml(&export_idml(&first)).unwrap();
assert_eq!(guides(&back), guides(&first), "round trip, {view:?}");
}
}
/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body
/// region (cells with `[None]` take it from there) and overridden on one cell.
const FIRST_BASELINE_STYLES: &str = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
+1
View File
@@ -20,6 +20,7 @@ designcraft-images = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
# The placed-PDF version check names hayro's PdfVersion (krilla::pdf::Pdf::version returns it).
hayro-syntax = "0.7.2"
+22 -13
View File
@@ -436,20 +436,14 @@ fn date_time(t: i64) -> krilla::metadata::DateTime {
.utc_offset_minute(0)
}
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian.
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. A time
/// outside chrono's year range still converts (the calendar repeats every 400 years, so the
/// instant is moved into range by whole cycles and only the year is carried, matching the old
/// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp.
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
(year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
use chrono::{Datelike, Timelike};
let (y, t) = designcraft_doc::time::utc_parts(t, 0);
(y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8)
}
/// A successful conversion and its name-independent diagnostic. Replayed once for each asset.
@@ -2117,4 +2111,19 @@ mod eps_tests {
assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}");
}
}
#[test]
fn civil_dates_match_the_old_cycle_conversion_including_beyond_chrono() {
use super::civil;
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
// Beyond chrono's year range the old `civil` still converted; pin that output, never the
// epoch. `date_time` is what clamps the year to the PDF `0..=9999` range afterwards.
assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0));
assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40));
assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
assert_eq!(civil(i64::MAX), (292277026596, 12, 4, 15, 30, 7));
assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
}
}
+1
View File
@@ -19,6 +19,7 @@ skrifa = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
zip = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+15 -13
View File
@@ -290,20 +290,14 @@ fn core(p: &Package) -> String {
s
}
/// Unix seconds → UTC civil date and time (proleptic Gregorian).
/// Unix seconds → UTC civil date and time (proleptic Gregorian), year clamped to the W3CDTF
/// `0..=9999` range. A time outside chrono's year range still converts (the calendar repeats every
/// 400 years, so the instant is moved into range by whole cycles and only the year is carried,
/// matching the old `civil`), then the year is clamped — never a silent reset to the epoch.
fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32)
use chrono::{Datelike, Timelike};
let (y, t) = designcraft_doc::time::utc_parts(t, 0);
(y.clamp(0, 9999), t.month(), t.day(), t.hour(), t.minute(), t.second())
}
/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects.
@@ -331,5 +325,13 @@ mod tests {
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
// Beyond chrono's year range the old `civil` still converted and clamped only the year to
// the W3CDTF `0..=9999` range; pin that (month/day/time keep the real instant), never the
// epoch, in both directions and at the `i64` extremes.
assert_eq!(civil(253_402_300_800), (9999, 1, 1, 0, 0, 0));
assert_eq!(civil(10_000_000_000_000), (9999, 5, 20, 17, 46, 40));
assert_eq!(civil(-10_000_000_000_000), (0, 8, 13, 6, 13, 20));
assert_eq!(civil(i64::MAX), (9999, 12, 4, 15, 30, 7));
assert_eq!(civil(i64::MIN), (0, 1, 27, 8, 29, 52));
}
}
+2
View File
@@ -76,6 +76,7 @@ pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end:
x_edges,
y_edges,
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
angle: None,
@@ -160,6 +161,7 @@ fn line_hit(cx: &ToolContext, spread: SpreadRef, point: Point, len: Option<f64>)
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [len, None],
angle: None,
+10
View File
@@ -140,6 +140,9 @@ pub struct SnapView {
pub smart_dimensions: bool,
pub smart_spacing: bool,
pub zone_px: f64,
/// View › Grids & Guides › Lock Guides: ruler guides can't be selected or moved.
#[serde(default)]
pub guides_locked: bool,
}
impl SnapView {
@@ -153,6 +156,7 @@ impl SnapView {
smart_dimensions: true,
smart_spacing: true,
zone_px: 4.0,
guides_locked: false,
};
pub const OFF: Self = Self {
snap_to_guides: false,
@@ -164,6 +168,7 @@ impl SnapView {
smart_dimensions: false,
smart_spacing: false,
zone_px: 4.0,
guides_locked: false,
};
/// Guides, the document grid, or smart guides are on.
@@ -186,6 +191,9 @@ pub enum Gesture {
Create,
Rotate,
Point,
/// A ruler guide being dragged: one line on one axis, with no size or spacing to match and
/// no pull from the baseline grid.
Guide,
}
pub struct SnapRequest<'a> {
@@ -197,6 +205,8 @@ pub struct SnapRequest<'a> {
/// Top, center, bottom.
pub y_edges: [bool; 3],
pub exclude: &'a [ItemId],
/// Ruler guides that are not targets: the ones being dragged.
pub exclude_guides: &'a [designcraft_doc::GuideSel],
pub copying: bool,
/// Proposed width and height for the dimensions pass. None when that length is not being set.
pub lengths: [Option<f64>; 2],
+62 -3
View File
@@ -2,7 +2,8 @@
//!
//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool and
//! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no
//! object), double-clicking a text frame switches to the Type tool and
//! double-clicking a handle fits the frame to its content (corner: both sides, edge: that one).
//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]).
//!
@@ -179,6 +180,42 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 {
(xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN)
}
/// The ruler guides a marquee `r` (canvas) crosses that can be selected, as `guide.select`
/// takes them: none while guides are hidden or locked.
fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec<Value> {
if !cx.snap.show_guides || cx.snap.guides_locked {
return vec![];
}
let mut out = Vec::new();
for slot in &cx.layout.slots {
let Some(sp) = cx.doc.spread(slot.spread) else { continue };
let pasteboard = slot.xf.inverse().transform_rect_bbox(cx.layout.pasteboard);
for (pi, pg) in sp.pages.iter().enumerate() {
for (gi, g) in pg.guides.iter().enumerate() {
if !g.editable_in(cx.doc) {
continue;
}
// The line as the canvas draws it: a spread guide crosses the pasteboard (a
// vertical one the spread's height and a little), a page guide its page.
let span = match (g.spread, g.orientation) {
(false, _) => pg.bounds(),
(true, designcraft_doc::Orientation::Horizontal) => pasteboard,
(true, designcraft_doc::Orientation::Vertical) => sp.bounds().inflate(0.0, 36.0),
};
let line = match g.orientation {
designcraft_doc::Orientation::Horizontal => Rect::new(span.x0, g.position, span.x1, g.position),
designcraft_doc::Orientation::Vertical => Rect::new(g.position, span.y0, g.position, span.y1),
};
let b = slot.xf.transform_rect_bbox(line);
if b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0 {
out.push(json!({"spread": slot.spread, "page": pi, "index": gi}));
}
}
}
}
out
}
pub struct SelectionTool {
direct: bool,
drag: Drag,
@@ -892,6 +929,7 @@ impl Tool for SelectionTool {
x_edges,
y_edges,
exclude: &exclude,
exclude_guides: &[],
copying: ev.mods.alt && !content,
lengths: [None, None],
angle: None,
@@ -965,6 +1003,7 @@ impl Tool for SelectionTool {
x_edges,
y_edges,
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [axis_length(x_edges, proposed.width(), pad_x), axis_length(y_edges, proposed.height(), pad_y)],
angle: None,
@@ -1017,6 +1056,7 @@ impl Tool for SelectionTool {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(resulting),
@@ -1055,6 +1095,7 @@ impl Tool for SelectionTool {
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1112,7 +1153,12 @@ impl Tool for SelectionTool {
}
}
}
vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))]
let guides = if ids.is_empty() { guides_crossing(cx, r) } else { vec![] };
if guides.is_empty() {
vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))]
} else {
vec![Action::Exec("guide.select".into(), json!({"guides": guides, "add": ev.mods.shift}))]
}
}
Drag::None | Drag::Place { .. } => vec![],
}
@@ -1133,7 +1179,20 @@ impl Tool for SelectionTool {
}
let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))];
if cx.selection.items.is_empty() {
return vec![];
// Selected ruler guides: the arrows nudge them, Delete deletes them.
if cx.selection.guides.is_empty() {
return vec![];
}
let nudge = |dx: f64, dy: f64| vec![Action::Exec("guide.moveBy".into(), json!({"dx": dx, "dy": dy}))];
return match key {
ToolKey::Left => nudge(-inc, 0.0),
ToolKey::Right => nudge(inc, 0.0),
ToolKey::Up => nudge(0.0, -inc),
ToolKey::Down => nudge(0.0, inc),
ToolKey::Delete | ToolKey::Backspace => vec![Action::Exec("edit.clear".into(), json!({}))],
ToolKey::Escape => vec![Action::Exec("guide.deselect".into(), json!({}))],
_ => vec![],
};
}
match key {
ToolKey::Left => mv(-inc, 0.0),
+52 -6
View File
@@ -31,6 +31,7 @@ pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec<Over
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -395,9 +396,10 @@ fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option<f6
/// Lines are `page.y0 + start + n * increment` for n >= 0 while the line is inside the page
/// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the
/// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not
/// turn this off. Only a page the rect crosses on x contributes.
/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide
/// ([`Gesture::Guide`]) doesn't snap to it.
fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides {
if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) {
return None;
}
let baseline = &cx.doc.settings.baseline_grid;
@@ -1000,7 +1002,8 @@ fn push_parent_lengths(out: &mut Vec<f64>, cx: &ToolContext, req: &SnapRequest<'
///
/// A page guide attracts when the moving rect overlaps that page on the other axis. A spread
/// guide uses the spread bounds. Locked guides are included, and so are the guides a page shows
/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it.
/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it, and not
/// when the request excludes it (the guides being dragged).
fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.snap_to_guides || !cx.snap.show_guides {
return None;
@@ -1010,10 +1013,15 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
let flags = axis_flags(req, axis);
let (moving_lo, moving_hi) = perp_ends(req.rect, axis);
let mut best: Option<Best> = None;
for page in &sp.pages {
for (pi, page) in sp.pages.iter().enumerate() {
let page_bounds = page.bounds();
// The page's own guides, then those it shows from its parent page.
for guide in page.guides.iter().cloned().chain(cx.doc.parent_guides(page)) {
// The page's own guides, less the excluded ones, then those it shows from its parent page.
let own = page
.guides
.iter()
.enumerate()
.filter(|&(gi, _)| !req.exclude_guides.contains(&designcraft_doc::GuideSel { spread: req.spread, page: pi, index: gi }));
for guide in own.map(|(_, g)| g.clone()).chain(cx.doc.parent_guides(page)) {
if !guide.visible_in(cx.doc) || !guide.position.is_finite() || guide.orientation != axis_orientation(axis) {
continue;
}
@@ -1427,6 +1435,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [true, true, true],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1443,6 +1452,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1475,6 +1485,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [true, true, true],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1504,6 +1515,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1515,6 +1527,28 @@ mod tests {
assert!(hit.guides.is_empty());
}
/// A dragged guide is not its own target: excluded, the guide at 104 doesn't pull a line at 102.
/// Nor does the baseline grid pull it.
#[test]
fn excluded_guide_is_not_a_target() {
let far = Rect::new(400.0, 400.0, 420.0, 420.0);
let (doc, _, _) = doc_with_items(far, far, 104.0);
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let cx = ctx_on(&doc, &sel, &cache, &layout);
let line = Rect::new(102.0, 100.0, 102.0, 300.0);
let dragged = [designcraft_doc::GuideSel { spread: SpreadRef::Doc(0), page: 0, index: 0 }];
let hit = snap(&cx, move_req(line, &[]));
assert!((hit.delta.x - 2.0).abs() < 1e-6, "{:?}", hit.delta);
let hit = snap(&cx, SnapRequest { exclude_guides: &dragged, ..move_req(line, &[]) });
assert_eq!(hit.delta.x, 0.0);
// A dragged guide passes the baseline grid's lines (36 + 12n), which a moved object meets.
let flat = Rect::new(100.0, 86.0, 300.0, 86.0);
assert!((snap(&cx, y_req(flat)).delta.y - -2.0).abs() < 1e-6);
assert_eq!(snap(&cx, SnapRequest { gesture: Gesture::Guide, ..y_req(flat) }).delta.y, 0.0);
}
#[test]
fn parent_centered_stroke_attracts_at_the_outer_edge() {
let mut doc = Document::new(&NewDocument::default());
@@ -1542,6 +1576,7 @@ mod tests {
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1569,6 +1604,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1599,6 +1635,7 @@ mod tests {
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1630,6 +1667,7 @@ mod tests {
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[id],
exclude_guides: &[],
copying: true,
lengths: [None, None],
angle: None,
@@ -1666,6 +1704,7 @@ mod tests {
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[original],
exclude_guides: &[],
copying: true,
lengths: [None, None],
angle: None,
@@ -1793,6 +1832,7 @@ mod tests {
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [Some(197.0), None],
angle: None,
@@ -1836,6 +1876,7 @@ mod tests {
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [Some(82.0), None],
angle: None,
@@ -1871,6 +1912,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -2009,6 +2051,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [Some(38.0), None],
angle: None,
@@ -2041,6 +2084,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(28.0),
@@ -2085,6 +2129,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(28.0),
@@ -2157,6 +2202,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(angle),
+2
View File
@@ -100,6 +100,7 @@ impl Tool for XformTool {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(resulting),
@@ -151,6 +152,7 @@ impl Tool for XformTool {
x_edges,
y_edges,
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [length_on(x_edges, w, pad_x), length_on(y_edges, h, pad_y)],
angle: None,
+442 -28
View File
@@ -2,7 +2,7 @@
//! selection, text ports and threads, the text caret, rulers, and pointer/keyboard input.
use designcraft_compose as compose;
use designcraft_doc::{Content, Document, Item, SpreadRef};
use designcraft_doc::{Content, Document, GuideSel, Item, SpreadRef};
use designcraft_engine::doc::Selection;
use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2};
use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, SpreadSlot, ToolKey};
@@ -531,21 +531,41 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, Color32::from_rgb(0x4a, 0x4a, 0x4a)), StrokeKind::Outside);
}
if !preview {
draw_guides(app, &painter, &xf, &doc, &layout, &t);
if let Some(g) = ui.data(|d| d.get_temp::<GuideDrag>(egui::Id::new(GUIDE_DRAG)))
let drag = ui.data(|d| d.get_temp::<GuideDrag>(egui::Id::new(GUIDE_DRAG)));
let press = ui.data(|d| d.get_temp::<GuidePress>(egui::Id::new(GUIDE_PRESS)));
let moving = drag.as_ref().zip(press).and_then(|(g, press)| moving_guides(app, &xf, rect, g, press));
draw_guides(app, &painter, &xf, &doc, &layout, &t, moving.as_ref());
if let Some(g) = drag
&& let Some(p) = g.at
{
let col = Color32::from_rgb(74, 227, 255);
let spot = guide_spot(app, &xf, &g);
let line = spot.as_ref().map_or(p, |s| s.screen);
let (a, b) = match g.orientation {
designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)),
designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)),
designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, line.y), pos2(rect.max.x, line.y)),
designcraft_doc::Orientation::Vertical => (pos2(line.x, rect.min.y), pos2(line.x, rect.max.y)),
};
// What caught it, as for a moved object.
for mark in spot.iter().flat_map(|s| &s.marks) {
if let Overlay::Guide { a, b } = mark {
painter.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, t.smart_guide));
}
}
// A new guide from the rulers with the Page tool is a liquid guide: dashed.
if g.from.is_none() && app.session.tool_id() == "page" {
dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0);
} else {
painter.line_segment([a, b], Stroke::new(1.0, col));
}
// Where it is, beside the pointer (#1282).
if let Some(text) = guide_readout(app, &xf, &g) {
let galley = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), t.text);
let at = readout_at(p, galley.size(), rect);
let bg = Rect::from_min_size(at, galley.size()).expand(3.0);
painter.rect_filled(bg, 2.0, t.panel);
painter.rect_stroke(bg, 2.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
painter.galley(at, galley, t.text);
}
}
draw_frames(app, &painter, &xf, &doc, &layout);
if app.ui.hidden_characters {
@@ -950,8 +970,11 @@ fn shown_area(app: &DesignApp, doc: &Document, xf: &Xf, slot: &SpreadSlot) -> Re
boxes.iter().filter_map(|o| sp.outset_rect(*o)).map(|r| xf.rect(slot.xf.transform_rect_bbox(r))).fold(trim, |a, b| a.union(b))
}
fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
/// `moving`: guides being dragged, drawn where they would be dropped instead of where they are.
fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens, moving: Option<&MovingGuides>) {
let s = &doc.settings;
let selection: std::collections::HashSet<GuideSel> =
app.session.active().map(|st| st.selection.guides.iter().copied().collect()).unwrap_or_default();
for slot in &layout.slots {
let Some(sp) = doc.spread(slot.spread) else { continue };
// Everything below is in spread coordinates, placed through the slot (which may be turned).
@@ -1007,8 +1030,21 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document
}
}
}
// The page's own guides, then those it shows from its parent page.
for g in p.guides.iter().cloned().chain(doc.parent_guides(p)).filter(|g| g.visible_in(doc)) {
// The page's own guides, then those it shows from its parent page. Selected guides are
// drawn thicker, in their layer's color.
let own = p.guides.len();
for (gi, mut g) in p.guides.iter().cloned().chain(doc.parent_guides(p)).enumerate().filter(|(_, g)| g.visible_in(doc)) {
let key = GuideSel { spread: slot.spread, page: pi, index: gi };
let selected = gi < own && selection.contains(&key);
if gi < own
&& let Some(m) = moving
&& m.guides.contains(&key)
{
g.position += match g.orientation {
designcraft_doc::Orientation::Vertical => m.dx,
designcraft_doc::Orientation::Horizontal => m.dy,
};
}
// Spread guides cross the pasteboard; page guides their page.
let span = if g.spread { pasteboard } else { pr };
let (a, bb) = match g.orientation {
@@ -1018,7 +1054,12 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document
(Point::new(g.position, span.y0), Point::new(g.position, span.y1))
}
};
let stroke = Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255));
let stroke = if selected {
let color = g.layer.and_then(|l| doc.layer(l)).map_or(Color32::from_rgb(79, 153, 255), |l| c32(l.color));
Stroke::new(2.0, color)
} else {
Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255))
};
if g.liquid {
// Liquid guides are dashed.
painter.extend(egui::Shape::dashed_line(&[pt(a), pt(bb)], stroke, 6.0, 3.0));
@@ -2013,6 +2054,11 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
/// coordinates: the ruler origin (one per page for a page origin) moved by zero point `zp`.
fn ruler_pieces(app: &DesignApp, doc: &Document, layout: &CanvasLayout, zp: [f64; 2]) -> Vec<(f64, f64, Point)> {
let Some(slot) = current_slot(app, layout).and_then(|i| layout.slots.get(i)) else { return Vec::new() };
ruler_pieces_of(doc, slot, zp)
}
/// [`ruler_pieces`] for the spread in `slot`.
fn ruler_pieces_of(doc: &Document, slot: &SpreadSlot, zp: [f64; 2]) -> Vec<(f64, f64, Point)> {
let x = |v: f64, y: f64| if v.is_finite() { slot.to_canvas(Point::new(v, y)).x } else { v };
doc.ruler_pieces(slot.spread)
.into_iter()
@@ -2375,6 +2421,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
if !space && !menu_open && xf.rot == 0 && zero_point_drag(app, ui, resp, rect, &xf) {
return;
}
// Hidden or locked guides can't stay selected.
if (!app.ui.guides || app.ui.guides_locked) && app.session.active().is_some_and(|st| !st.selection.guides.is_empty()) {
let _ = app.run("guide.deselect", json!({}));
}
if !space && !menu_open && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) {
return;
}
@@ -2839,6 +2889,184 @@ struct GuideDrag {
from: Option<(designcraft_doc::SpreadRef, usize, usize)>,
/// Pointer position (screen).
at: Option<Pos2>,
/// Modifiers held at the pointer's last move: Shift snaps to the ruler's ticks, Command
/// suspends snapping.
mods: egui::Modifiers,
}
const GUIDE_PRESS: &str = "canvas_guide_press";
/// A press on an existing guide: where it was (screen) and whether Shift was down. Released
/// where it was pressed, it is a click, which selects the guide.
#[derive(Clone, Copy, Debug)]
struct GuidePress {
origin: Pos2,
shift: bool,
}
/// How far (screen pixels) the pointer may move between press and release for a click.
const GUIDE_CLICK_SLOP: f32 = 3.0;
/// Where a dragged guide is, as the rulers measure it (#1282): `X: …` for a vertical guide, `Y: …`
/// for a horizontal one, from the zero point (and the page origin, when rulers restart per page)
/// in the document's ruler units. `None` with no document or rulers.
fn guide_readout(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<String> {
let (label, value, unit) = guide_measure(app, xf, g)?;
Some(format!("{label}: {}", designcraft_geom::units::format_measure(value, unit)))
}
/// Where a pointer readout of `size` goes for a pointer at `p`: below right of it, on the other
/// side where that would leave the canvas `area` (near its right or bottom edge), and kept inside
/// it (with the label's 3-point frame) however small the canvas is.
fn readout_at(p: Pos2, size: egui::Vec2, area: Rect) -> Pos2 {
const GAP: f32 = 12.0;
const PAD: f32 = 3.0;
let x = if p.x + GAP + size.x + PAD > area.max.x { p.x - GAP - size.x } else { p.x + GAP };
let y = if p.y + GAP + size.y + PAD > area.max.y { p.y - GAP - size.y } else { p.y + GAP };
let clamp = |v: f32, lo: f32, hi: f32| if hi < lo { lo } else { v.clamp(lo, hi) };
pos2(clamp(x, area.min.x + PAD, area.max.x - PAD - size.x), clamp(y, area.min.y + PAD, area.max.y - PAD - size.y))
}
/// [`guide_readout`]'s parts: the axis label, the value (points) and its unit.
fn guide_measure(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<(&'static str, f64, designcraft_geom::Unit)> {
let p = g.at?;
let st = app.session.active()?;
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
// Measured where the guide is dropped (`guide_drag`): its snapped spot, on the spread under
// the pointer, as the rulers measure that spread, not the one in the middle of the view.
let (c, slot) = match guide_spot(app, xf, g) {
Some(spot) => (layout.to_canvas(spot.spread, spot.sp), layout.slot(spot.spread)?),
None => (xf.to_canvas(p), current_slot(app, &layout).and_then(|i| layout.slots.get(i))?),
};
// An existing guide moves only on its own spread (`guide_drag`): over another, dropping it
// leaves it where it was, so there is no place to show.
if g.from.is_some_and(|(r, _, _)| r != slot.spread) {
return None;
}
let pieces = ruler_pieces_of(&st.doc, slot, st.doc.zero_point());
let first = pieces.first()?;
let (label, value, unit) = match g.orientation {
designcraft_doc::Orientation::Vertical => {
// The horizontal ruler restarts at each page: the piece under the guide measures it.
let o = pieces.iter().find(|(from, to, _)| *from <= c.x && c.x < *to).unwrap_or(first).2;
("X", c.x - o.x, st.doc.settings.horizontal_units)
}
designcraft_doc::Orientation::Horizontal => ("Y", c.y - first.2.y, st.doc.settings.vertical_units),
};
value.is_finite().then_some((label, value, unit))
}
/// Where a dragged ruler guide is (#1221).
struct GuideSpot {
/// The spread under the pointer.
spread: SpreadRef,
/// The pointer in that spread's coordinates.
pointer: Point,
/// [`Self::pointer`] with the guide's axis snapped.
sp: Point,
/// [`Self::sp`] on screen, where the preview line goes.
screen: Pos2,
/// Alignment marks (canvas coordinates) when an object or page edge or center caught it.
marks: Vec<Overlay>,
}
/// Where the guide of drag `g` is: its axis (x for a vertical guide, y for a horizontal one)
/// snapped as a moved object's is, to ruler guides, margins, columns, bleed, the document grid and
/// smart-guide targets (not the baseline grid, which would catch a horizontal guide almost
/// anywhere). Shift snaps to the ruler's ticks instead and Command suspends snapping. The guides
/// being dragged are not targets. An existing guide over another spread, where it
/// can't be dropped, stays at the pointer. The preview, the readout and the drop all read it.
/// `None` with no spread under the pointer.
fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<GuideSpot> {
let p = g.at?;
let st = app.session.active()?;
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let (r, pointer) = layout.spread_at(xf.to_canvas(p))?;
let spot = |sp: Point, marks: Vec<Overlay>| GuideSpot { spread: r, pointer, sp, screen: xf.to_screen(layout.to_canvas(r, sp)), marks };
if g.mods.command || g.from.is_some_and(|(from, _, _)| from != r) {
return Some(GuideSpot { spread: r, pointer, sp: pointer, screen: p, marks: Vec::new() });
}
let vertical = matches!(g.orientation, designcraft_doc::Orientation::Vertical);
let place = |v: f64| if vertical { Point::new(v, pointer.y) } else { Point::new(pointer.x, v) };
if g.mods.shift {
let slot = layout.slot(r)?;
let at = ruler_tick_of(&st.doc, slot, g.orientation, xf.zoom, pointer).map_or(pointer, place);
return Some(spot(at, Vec::new()));
}
let spread = st.doc.spread(r)?;
// Not targets: the dragged guide, and the others of a selected group, which move with it.
let exclude: Vec<GuideSel> = match g.from {
Some((sr, pi, gi)) if drags_group(app, GuideSel { spread: sr, page: pi, index: gi }) => st.selection.guides.to_vec(),
Some((sr, pi, gi)) => vec![GuideSel { spread: sr, page: pi, index: gi }],
None => Vec::new(),
};
// How far the guide reaches across its axis: its page, or the whole spread.
let page = match g.from {
Some((_, pi, gi)) => spread.pages.get(pi).filter(|pg| pg.guides.get(gi).is_some_and(|guide| !guide.spread)),
None => spread.pages.iter().find(|pg| pg.bounds().contains(pointer)),
};
let reach = page.map_or_else(|| spread.bounds(), |pg| pg.bounds());
let (at, rect, x_edges, y_edges) = if vertical {
(pointer.x, DRect::new(pointer.x, reach.y0, pointer.x, reach.y1), [true; 3], [false; 3])
} else {
(pointer.y, DRect::new(reach.x0, pointer.y, reach.x1, pointer.y), [false; 3], [true; 3])
};
let cx = designcraft_tools::ToolContext {
doc: &st.doc,
selection: &st.selection,
cache: &app.session.cache,
layout: &layout,
zoom: xf.zoom.max(1e-6),
layer: st.active_layer,
snap: app.ui.snap_view(),
unit: app.ui.units,
stroke_in_front: false,
};
let s = designcraft_tools::snap::snap(
&cx,
designcraft_tools::SnapRequest {
spread: r,
gesture: designcraft_tools::Gesture::Guide,
rect,
x_edges,
y_edges,
exclude: &[],
exclude_guides: &exclude,
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer,
},
);
let snapped = at + if vertical { s.delta.x } else { s.delta.y };
Some(if snapped.is_finite() { spot(place(snapped), s.guides) } else { spot(pointer, Vec::new()) })
}
/// The nearest ruler tick to spread point `sp` along a guide of `orientation`'s axis, in spread
/// coordinates: the horizontal ruler's (the piece under the point) where that axis runs across the
/// screen, else the vertical ruler's. `None` when the ruler has no ticks there.
fn ruler_tick_of(doc: &Document, slot: &SpreadSlot, orientation: designcraft_doc::Orientation, zoom: f64, sp: Point) -> Option<f64> {
let vertical = matches!(orientation, designcraft_doc::Orientation::Vertical);
// A quarter-turned spread runs its x axis down the screen.
let across = vertical == slot.rotation.is_multiple_of(2);
let c = slot.to_canvas(sp);
let pieces = ruler_pieces_of(doc, slot, doc.zero_point());
let step = |unit: designcraft_geom::Unit| {
let (major, sub) = unit.ruler_ticks(zoom);
let step = major / f64::from(sub.max(1));
(step.is_finite() && step > 0.0).then_some(step)
};
let tick = |v: f64, o: f64, step: f64| o + ((v - o) / step).round() * step;
let c = if across {
let &(_, _, o) = pieces.iter().find(|(from, to, _)| *from <= c.x && c.x < *to).or(pieces.first())?;
Point::new(tick(c.x, o.x, step(doc.settings.horizontal_units)?), c.y)
} else {
let o = pieces.first()?.2;
Point::new(c.x, tick(c.y, o.y, step(doc.settings.vertical_units)?))
};
let s = slot.to_spread(c);
Some(if vertical { s.x } else { s.y }).filter(|v| v.is_finite())
}
/// The guide under a screen point (within 3 px), as (spread, page, index, orientation).
@@ -2870,8 +3098,10 @@ fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::Sprea
None
}
/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools, not over an
/// object) to move it, drop it back on a ruler to delete it. Returns true while it owns the pointer.
/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools) to move it, drop
/// it back on a ruler to delete it. A press near an unlocked guide takes it even over an object:
/// the guide is selected and the object is not. Returns true from the press to the release, so
/// the tools never see that press.
fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool {
let id = egui::Id::new(GUIDE_DRAG);
let (pressed, released, origin, latest) =
@@ -2890,29 +3120,27 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
None
};
if let Some(orientation) = orientation {
drag = Some(GuideDrag { orientation, from: None, at: Some(o) });
drag = Some(GuideDrag { orientation, from: None, at: Some(o), mods: egui::Modifiers::NONE });
}
} else if rect.contains(o)
&& matches!(app.session.tool_id(), "selection" | "directSelection")
&& let Some((r, pi, gi, orientation)) = guide_at(app, xf, o)
{
// Objects take precedence over the guides behind them.
let over_item = app.session.active().is_some_and(|st| {
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
layout.spread_at(xf.to_canvas(o)).is_some_and(|(sr, p)| match sr {
designcraft_doc::SpreadRef::Doc(si) => st.doc.hit_item(si, p, 3.0 / xf.zoom).is_some(),
_ => false,
})
});
if !over_item {
drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o) });
let shift = ui.input(|i| i.modifiers.shift);
// A press on a guide selects it (Shift: on release, as a click adds or removes it).
let selected = app.session.active().is_some_and(|st| st.selection.guides.contains(&GuideSel { spread: r, page: pi, index: gi }));
if !shift && !selected {
let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi}));
}
ui.data_mut(|d| d.insert_temp(egui::Id::new(GUIDE_PRESS), GuidePress { origin: o, shift }));
drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o), mods: egui::Modifiers::NONE });
}
}
let Some(mut g) = drag else { return false };
if let Some(p) = latest {
g.at = Some(p);
}
g.mods = ui.input(|i| i.modifiers);
if !released {
ui.ctx().set_cursor_icon(match g.orientation {
designcraft_doc::Orientation::Horizontal => egui::CursorIcon::ResizeVertical,
@@ -2922,14 +3150,29 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
return true;
}
ui.data_mut(|d| d.remove::<GuideDrag>(id));
let press: Option<GuidePress> = ui.data_mut(|d| {
let press = d.get_temp(egui::Id::new(GUIDE_PRESS));
d.remove::<GuidePress>(egui::Id::new(GUIDE_PRESS));
press
});
let Some(p) = g.at else { return true };
if let (Some((r, pi, gi)), Some(press)) = (g.from, press)
&& (p - press.origin).length() <= GUIDE_CLICK_SLOP
{
// A click: the guide is selected (at the press); Shift adds it or removes it.
if press.shift {
let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi, "toggle": true}));
}
return true;
}
// Dragging one of several selected guides moves (or deletes) them all.
let group = g.from.is_some_and(|(spread, page, index)| drags_group(app, GuideSel { spread, page, index }));
let on_canvas = rect.contains(p);
let target = app.session.active().and_then(|st| {
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let (r, sp) = layout.spread_at(xf.to_canvas(p))?;
let spread = st.doc.spread(r)?;
let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(sp));
Some((r, sp, on_page))
// Placed where the preview showed it (snapped); the page under the pointer owns a new guide.
let target = guide_spot(app, xf, &g).and_then(|spot| {
let spread = app.session.active()?.doc.spread(spot.spread)?;
let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(spot.pointer));
Some((spot.spread, spot.sp, on_page))
});
let pos_of = |sp: Point| match g.orientation {
designcraft_doc::Orientation::Horizontal => sp.y,
@@ -2941,9 +3184,17 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
};
match (g.from, on_canvas, target) {
// Dropped back on a ruler: delete.
(Some(_), false, _) if group => {
let _ = app.run("guide.delete", json!({}));
}
(Some((r, pi, gi)), false, _) => {
let _ = app.run("guide.delete", json!({"spread": r, "page": pi, "index": gi}));
}
(Some((r, _, _)), true, Some((tr, _, _))) if tr == r && group => {
if let Some((dx, dy)) = press.and_then(|press| guide_drag_delta(app, xf, &g, press.origin)) {
let _ = app.run("guide.moveBy", json!({"dx": dx, "dy": dy}));
}
}
(Some((r, pi, gi)), true, Some((tr, sp, _))) if tr == r => {
let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)}));
}
@@ -2962,6 +3213,56 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
true
}
/// Whether dragging guide `from` takes the other selected guides along: it is one of several
/// selected.
fn drags_group(app: &DesignApp, from: GuideSel) -> bool {
app.session.active().is_some_and(|st| st.selection.guides.len() > 1 && st.selection.guides.contains(&from))
}
/// How far a drag of the existing guide `g.from`, pressed at `origin` (screen), moves guides, in
/// spread coordinates: the dragged guide goes to its [`guide_spot`], the other selected guides as
/// far along their own axis as it moved. `None` while the pointer is not over the guide's own
/// spread, where a drop leaves it in place.
fn guide_drag_delta(app: &DesignApp, xf: &Xf, g: &GuideDrag, origin: Pos2) -> Option<(f64, f64)> {
let (r, pi, gi) = g.from?;
let spot = guide_spot(app, xf, g)?;
if spot.spread != r {
return None;
}
let sp = spot.sp;
let st = app.session.active()?;
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let start = layout.slot(r)?.to_spread(xf.to_canvas(origin));
let at = st.doc.spread(r)?.pages.get(pi)?.guides.get(gi)?.position;
let (dx, dy) = match g.orientation {
designcraft_doc::Orientation::Vertical => (sp.x - at, sp.y - start.y),
designcraft_doc::Orientation::Horizontal => (sp.x - start.x, sp.y - at),
};
(dx.is_finite() && dy.is_finite()).then_some((dx, dy))
}
/// Guides being dragged on the canvas and how far they have moved (spread coordinates).
struct MovingGuides {
guides: std::collections::HashSet<GuideSel>,
dx: f64,
dy: f64,
}
/// What a drag of an existing guide moves, for the preview: the dragged guide, or every selected
/// guide when it is one of several selected. `None` before the drag leaves the click distance,
/// over a ruler (a drop there deletes) and off the guide's spread.
fn moving_guides(app: &DesignApp, xf: &Xf, rect: Rect, g: &GuideDrag, press: GuidePress) -> Option<MovingGuides> {
let (spread, page, index) = g.from?;
let p = g.at?;
if !rect.contains(p) || (p - press.origin).length() <= GUIDE_CLICK_SLOP {
return None;
}
let (dx, dy) = guide_drag_delta(app, xf, g, press.origin)?;
let from = GuideSel { spread, page, index };
let guides = if drags_group(app, from) { app.session.active()?.selection.guides.iter().copied().collect() } else { [from].into_iter().collect() };
Some(MovingGuides { guides, dx, dy })
}
/// Presses in a row on the canvas: the last press's count, and where and when it was released
/// (`at` is −∞ while the button is down).
#[derive(Clone, Copy, Debug)]
@@ -2991,6 +3292,10 @@ fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks {
#[path = "parent_canvas_tests.rs"]
mod parent_canvas_tests;
#[cfg(test)]
#[path = "guide_selection_tests.rs"]
mod guide_selection_tests;
#[cfg(test)]
mod tests {
use designcraft_geom::Unit;
@@ -4033,6 +4338,115 @@ mod tests {
assert_eq!(doc.settings.zero_point, [0.0, 0.0], "the zero point stays");
}
/// A guide being dragged out of a ruler says where it is, as the rulers measure it (#1282):
/// from the zero point, in the ruler units, and the label follows the pointer while it moves.
#[test]
fn a_dragged_guide_shows_where_it_is() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("view.zeroPoint", json!({"at": [100, 50]})).unwrap();
let mut h = harness(app);
let rect = h.state().canvas_rect.unwrap();
let dest = rect.min + vec2(160.0, 110.0);
let (xf, at) = {
let app = h.state();
let xf = Xf::new(rect, app.view().unwrap());
let st = app.session.active().unwrap();
(xf, CanvasLayout::new(&st.doc, false).spread_at(xf.to_canvas(dest)).unwrap().1)
};
let units = h.state().session.doc().unwrap().doc.settings.horizontal_units;
for (orientation, label, want) in
[(designcraft_doc::Orientation::Vertical, "X", at.x - 100.0), (designcraft_doc::Orientation::Horizontal, "Y", at.y - 50.0)]
{
let g = GuideDrag { orientation, from: None, at: Some(dest), mods: egui::Modifiers::NONE };
let (l, v, _) = guide_measure(h.state(), &xf, &g).unwrap();
assert_eq!(l, label);
assert!((v - want).abs() < 1e-6, "{label}: {v} vs {want}");
let text = guide_readout(h.state(), &xf, &g).unwrap();
assert_eq!(text, format!("{label}: {}", designcraft_geom::units::format_measure(v, units)));
}
// While a guide is actually being dragged out of the top ruler, the readout is there.
let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0);
h.hover_at(ruler);
h.step();
primary(&mut h, ruler, true);
h.event(egui::Event::PointerMoved(dest));
h.run_steps(2);
let g = h.ctx.data(|d| d.get_temp::<GuideDrag>(egui::Id::new(GUIDE_DRAG))).expect("dragging");
assert_eq!(g.orientation, designcraft_doc::Orientation::Horizontal);
assert!(guide_readout(h.state(), &xf, &g).is_some_and(|t| t.starts_with("Y: ")));
primary(&mut h, dest, false);
}
/// Over a spread other than the one in the middle of the view, the readout measures that
/// spread, the one the guide is dropped on: it reads the position the guide gets.
#[test]
fn the_guide_readout_measures_the_spread_under_the_pointer() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 5})).unwrap();
app.run("view.zeroPoint", json!({"at": [100, 50]})).unwrap();
let h = harness(app);
let rect = h.state().canvas_rect.unwrap();
let xf = Xf::new(rect, h.state().view().unwrap());
let st = h.state().session.active().unwrap();
let layout = CanvasLayout::new(&st.doc, false);
let current = current_slot(h.state(), &layout).unwrap();
// A point inside the last spread, well away from the one in view.
let last = layout.slots.last().unwrap();
assert_ne!(layout.slots.iter().position(|s| s.spread == last.spread), Some(current), "the last spread isn't the one in view");
let inside = Point::new(last.bounds.center().x, last.bounds.y0 + (last.bounds.y1 - last.bounds.y0) * 0.3);
let (r, sp) = layout.spread_at(inside).unwrap();
assert_eq!(r, last.spread);
let g = GuideDrag {
orientation: designcraft_doc::Orientation::Horizontal,
from: None,
at: Some(xf.to_screen(inside)),
mods: egui::Modifiers::NONE,
};
let (label, v, _) = guide_measure(h.state(), &xf, &g).unwrap();
assert_eq!(label, "Y");
// The guide would be stored at `sp.y` on that spread, which its ruler reads from the zero point.
// (To screen and back in f32: a few hundred-thousandths of a point.)
assert!((v - (sp.y - 50.0)).abs() < 1e-3, "{v} vs {}", sp.y - 50.0);
// An existing guide of another spread can't be dropped there (it only moves on its own
// spread): no readout over this one, a readout over its own.
let first = layout.slots[current].spread;
let moving = GuideDrag {
orientation: designcraft_doc::Orientation::Horizontal,
from: Some((first, 0, 0)),
at: Some(xf.to_screen(inside)),
mods: egui::Modifiers::NONE,
};
assert!(guide_measure(h.state(), &xf, &moving).is_none(), "an existing guide over another spread");
let own = layout.slots[current].bounds;
let over_own = Point::new(own.center().x, own.y0 + (own.y1 - own.y0) * 0.3);
let moving_here = GuideDrag { at: Some(xf.to_screen(over_own)), ..moving };
assert!(guide_measure(h.state(), &xf, &moving_here).is_some(), "over its own spread");
}
/// The guide readout stays on the canvas: below right of the pointer in the open, flipped
/// left or up near the right or bottom edge, and inside the canvas in every corner.
#[test]
fn the_guide_readout_stays_inside_the_canvas() {
let area = Rect::from_min_max(pos2(50.0, 40.0), pos2(850.0, 640.0));
let size = vec2(80.0, 14.0);
let inside = |at: Pos2| {
let r = Rect::from_min_size(at, size).expand(3.0);
r.min.x >= area.min.x - 1e-3 && r.min.y >= area.min.y - 1e-3 && r.max.x <= area.max.x + 1e-3 && r.max.y <= area.max.y + 1e-3
};
assert_eq!(readout_at(pos2(400.0, 300.0), size, area), pos2(412.0, 312.0), "in the open: below right");
let right = readout_at(pos2(845.0, 300.0), size, area);
assert!(right.x + size.x < 845.0 && inside(right), "near the right edge: left of the pointer, {right:?}");
let bottom = readout_at(pos2(400.0, 636.0), size, area);
assert!(bottom.y + size.y < 636.0 && inside(bottom), "near the bottom: above it, {bottom:?}");
for p in [area.min, pos2(area.max.x, area.min.y), area.max, pos2(area.min.x, area.max.y), pos2(-500.0, 2000.0)] {
assert!(inside(readout_at(p, size, area)), "{p:?}");
}
// A canvas narrower than the label: pinned to its top left, not past it.
let tiny = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 10.0));
assert_eq!(readout_at(pos2(20.0, 5.0), size, tiny), pos2(3.0, 3.0));
}
#[test]
fn rulers_and_xy_fields_measure_from_the_zero_point_on_every_spread() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+332
View File
@@ -0,0 +1,332 @@
//! Ruler guides selected like objects (#1282): click, Shift-click, marquee, drag, keys, and the
//! X/Y fields.
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::Harness;
use super::*;
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
/// A page with vertical guides at x 200 and 300 and a horizontal one at y 400.
fn with_guides() -> Harness<'static, DesignApp> {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
for (orientation, position) in [("vertical", 200.0), ("vertical", 300.0), ("horizontal", 400.0)] {
app.run("guide.add", json!({"orientation": orientation, "position": position})).unwrap();
}
harness(app)
}
/// Screen position of spread 0's point (x, y).
fn screen_of(h: &Harness<'_, DesignApp>, x: f64, y: f64) -> Pos2 {
let app = h.state();
let st = app.session.active().unwrap();
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
xf.to_screen(CanvasLayout::new(&st.doc, st.editing_parents).xf(SpreadRef::Doc(0)) * Point::new(x, y))
}
fn press(h: &mut Harness<'_, DesignApp>, pos: Pos2, pressed: bool, mods: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: mods });
h.run_steps(2);
}
fn click(h: &mut Harness<'_, DesignApp>, pos: Pos2, mods: Modifiers) {
h.event(Event::ModifiersChanged(mods));
h.hover_at(pos);
h.step();
press(h, pos, true, mods);
press(h, pos, false, mods);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) {
h.hover_at(a);
h.step();
press(h, a, true, Modifiers::NONE);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.step();
}
press(h, b, false, Modifiers::NONE);
h.run_steps(2);
}
fn selected(h: &Harness<'_, DesignApp>) -> Vec<usize> {
h.state().session.doc().unwrap().selection.guides.iter().map(|g| g.index).collect()
}
fn positions(h: &Harness<'_, DesignApp>) -> Vec<f64> {
h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.iter().map(|g| g.position).collect()
}
#[test]
fn clicking_guides_selects_them_and_shift_click_adds_or_removes() {
let mut h = with_guides();
let id = h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap();
h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap();
let at = screen_of(&h, 200.0, 600.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(selected(&h), [0]);
assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the object is deselected");
assert_eq!(positions(&h), [200.0, 300.0, 400.0], "a click doesn't move the guide");
let at = screen_of(&h, 100.0, 400.0);
click(&mut h, at, Modifiers::SHIFT);
assert_eq!(selected(&h), [0, 2]);
let at = screen_of(&h, 200.0, 650.0);
click(&mut h, at, Modifiers::SHIFT);
assert_eq!(selected(&h), [2]);
// A click on empty canvas, or on an object, clears the guide selection.
let at = screen_of(&h, 550.0, 650.0);
click(&mut h, at, Modifiers::NONE);
assert!(selected(&h).is_empty());
let at = screen_of(&h, 300.0, 600.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(selected(&h), [1]);
let at = screen_of(&h, 450.0, 150.0);
click(&mut h, at, Modifiers::NONE);
assert!(selected(&h).is_empty());
assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]);
// With View › Lock Guides on, guides can't be selected, and selected ones are deselected.
h.state_mut().run("guide.selectAll", json!({})).unwrap();
h.state_mut().ui.guides_locked = true;
h.run_steps(2);
assert!(selected(&h).is_empty());
let at = screen_of(&h, 200.0, 600.0);
click(&mut h, at, Modifiers::NONE);
assert!(selected(&h).is_empty());
}
#[test]
fn dragging_a_selected_guide_moves_all_selected_guides_as_one_step() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap();
let undo = h.state().session.doc().unwrap().history.undo.len();
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0));
drag(&mut h, a, b);
let p = positions(&h);
assert!((p[0] - 250.0).abs() < 1.0 && p[1] == 300.0 && (p[2] - 420.0).abs() < 1.0, "{p:?}");
assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(selected(&h), [0, 2]);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(positions(&h), [200.0, 300.0, 400.0]);
// Dragging an unselected guide selects it and moves only it.
let (a, b) = (screen_of(&h, 300.0, 600.0), screen_of(&h, 350.0, 600.0));
drag(&mut h, a, b);
assert_eq!(selected(&h), [1]);
let p = positions(&h);
assert!(p[0] == 200.0 && (p[1] - 350.0).abs() < 1.0 && p[2] == 400.0, "{p:?}");
}
#[test]
fn a_marquee_selects_guides_only_when_it_touches_no_object() {
let mut h = with_guides();
// Across both vertical guides, below the horizontal one, on empty page.
let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0));
drag(&mut h, a, b);
assert_eq!(selected(&h), [0, 1]);
let id = h.state_mut().run("frame.create", json!({"rect": [220, 520, 260, 540]})).unwrap()["id"].as_u64().unwrap();
h.state_mut().run("edit.deselectAll", json!({})).unwrap();
let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0));
drag(&mut h, a, b);
assert!(selected(&h).is_empty());
assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]);
}
#[test]
fn keys_nudge_and_delete_selected_guides() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 1}, {"page": 0, "index": 2}]})).unwrap();
let inc = h.state().session.doc().unwrap().doc.settings.keyboard_increment;
let vi = h.state().view_info();
let key = |h: &mut Harness<'_, DesignApp>, k: ToolKey, shift: bool| {
let m = Mods { shift, alt: false, cmd: false, ctrl: false, space: false };
assert!(h.state_mut().session.tool_key(k, m, vi).unwrap());
};
key(&mut h, ToolKey::Right, false);
key(&mut h, ToolKey::Down, true);
assert_eq!(positions(&h), [200.0, 300.0 + inc, 400.0 + 10.0 * inc]);
key(&mut h, ToolKey::Delete, false);
assert_eq!(positions(&h), [200.0]);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(positions(&h).len(), 3);
assert_eq!(selected(&h), [1, 2]);
key(&mut h, ToolKey::Escape, false);
assert!(selected(&h).is_empty());
}
#[test]
fn the_guide_field_measures_from_the_zero_point() {
let mut h = with_guides();
h.state_mut().run("view.zeroPoint", json!({"at": [100, 50]})).unwrap();
h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap();
let f = crate::panels::guide_field(h.state()).unwrap();
assert_eq!((f.row(), f.value), (0, 100.0));
h.state_mut().run("guide.move", f.move_to(36.0)).unwrap();
assert_eq!(positions(&h)[0], 136.0);
h.state_mut().run("guide.select", json!({"page": 0, "index": 2})).unwrap();
let f = crate::panels::guide_field(h.state()).unwrap();
assert_eq!((f.row(), f.value), (1, 350.0));
// Several guides: no field value.
h.state_mut().run("guide.selectAll", json!({})).unwrap();
assert!(crate::panels::guide_field(h.state()).is_none());
}
#[test]
fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() {
let mut h = with_guides();
assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G"));
crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null);
assert_eq!(selected(&h), [0, 1, 2]);
assert_eq!(crate::i18n::tr("de", "Select All Guides"), "Alle Hilfslinien auswählen");
for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
assert_ne!(crate::i18n::tr(code, "Select All Guides"), "Select All Guides", "{code}");
assert_ne!(crate::i18n::tr(code, "Ruler Guide"), "Ruler Guide", "{code}");
}
h.state_mut().ui.guides = false;
assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll"));
}
#[test]
fn a_guide_over_a_frame_takes_the_click() {
let mut h = with_guides();
h.state_mut().run("guide.deselect", json!({})).unwrap();
// The vertical guide at x 200 crosses the frame.
let id = h.state_mut().run("frame.create", json!({"rect": [150, 500, 260, 560]})).unwrap()["id"].as_u64().unwrap();
h.state_mut().run("edit.deselectAll", json!({})).unwrap();
let bounds = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].items[0].bounds();
let before = bounds(&h);
let at = screen_of(&h, 200.0, 530.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(selected(&h), [0]);
assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the frame is not selected");
// Dragging it there moves the guide, not the frame.
let (a, b) = (screen_of(&h, 200.0, 530.0), screen_of(&h, 230.0, 530.0));
drag(&mut h, a, b);
assert!((positions(&h)[0] - 230.0).abs() < 1.0, "{:?}", positions(&h));
assert_eq!(bounds(&h), before);
assert!(h.state().session.doc().unwrap().selection.items.is_empty());
// Away from the guide, a click inside the frame selects it.
let at = screen_of(&h, 160.0, 530.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]);
assert!(selected(&h).is_empty());
}
#[test]
fn a_guide_dragged_out_of_a_ruler_is_the_selection() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap();
let rect = h.state().canvas_rect.unwrap();
let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0);
let dest = screen_of(&h, 250.0, 500.0);
drag(&mut h, ruler, dest);
let p = positions(&h);
assert!(p.len() == 4 && (p[3] - 500.0).abs() < 1.0, "{p:?}");
assert_eq!(selected(&h), [3]);
}
#[test]
fn dragging_one_of_several_selected_guides_previews_them_all() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap();
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0));
let g =
GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE };
let press = GuidePress { origin: a, shift: false };
let app = h.state();
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let m = moving_guides(app, &xf, app.canvas_rect.unwrap(), &g, press).expect("moving");
assert!((m.dx - 50.0).abs() < 1.0 && (m.dy - 20.0).abs() < 1.0, "{} {}", m.dx, m.dy);
let mut moved: Vec<usize> = m.guides.iter().map(|g| g.index).collect();
moved.sort_unstable();
assert_eq!(moved, [0, 2]);
// The drop moves them by the same amounts.
let (dx, dy) = guide_drag_delta(app, &xf, &g, a).unwrap();
assert_eq!((dx, dy), (m.dx, m.dy));
}
/// Spread points per screen pixel at the harness's view.
fn pt_per_px(h: &Harness<'_, DesignApp>) -> f64 {
let app = h.state();
1.0 / Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).zoom
}
/// A letter page with two columns: the left column's right side.
fn with_columns() -> (Harness<'static, DesignApp>, f64) {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"preset": "Letter", "columns": 2, "gutter": 18, "margins": 54})).unwrap();
app.run("guide.add", json!({"orientation": "vertical", "position": 200.0})).unwrap();
let side = app.session.doc().unwrap().doc.spreads[0].pages[0].column_rects()[0].x1;
(harness(app), side)
}
/// A dragged guide snaps to a column guide as a moved object does (#1221).
#[test]
fn a_dragged_guide_snaps_to_a_column_guide() {
let (mut h, side) = with_columns();
let near = side + 1.5 * pt_per_px(&h);
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0));
drag(&mut h, a, b);
assert_eq!(positions(&h), [side]);
}
/// Snap to Guides and Smart Guides off: the guide goes where the pointer is.
#[test]
fn with_snapping_off_a_dragged_guide_stays_at_the_pointer() {
let (mut h, side) = with_columns();
h.state_mut().ui.snap_to_guides = false;
h.state_mut().ui.smart_guides = false;
let near = side + 1.5 * pt_per_px(&h);
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0));
drag(&mut h, a, b);
let p = positions(&h);
assert!((p[0] - near).abs() < 0.1 && p[0] != side, "{p:?} vs {near}");
}
/// A frame's edge catches a dragged guide, and the preview shows the alignment mark.
#[test]
fn a_dragged_guide_snaps_to_a_frame_edge_with_a_smart_guide() {
let mut h = with_guides();
h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap();
h.state_mut().run("edit.deselectAll", json!({})).unwrap();
let near = 400.0 + 1.5 * pt_per_px(&h);
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0));
let g =
GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE };
{
let app = h.state();
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let spot = guide_spot(app, &xf, &g).unwrap();
assert_eq!(spot.sp.x, 400.0);
assert!(spot.marks.iter().any(|m| matches!(m, Overlay::Guide { .. })), "{:?}", spot.marks);
// Command suspends snapping.
let spot = guide_spot(app, &xf, &GuideDrag { mods: Modifiers::COMMAND, ..g.clone() }).unwrap();
assert!((spot.sp.x - near).abs() < 0.1 && spot.marks.is_empty());
}
drag(&mut h, a, b);
assert_eq!(positions(&h), [400.0, 300.0, 400.0]);
}
/// A new guide dragged out of a ruler snaps too: here to the horizontal guide at y 400.
#[test]
fn a_guide_dragged_out_of_a_ruler_snaps() {
let mut h = with_guides();
let rect = h.state().canvas_rect.unwrap();
let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0);
let dest = screen_of(&h, 250.0, 400.0 + 1.5 * pt_per_px(&h));
drag(&mut h, ruler, dest);
assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]);
}
+10 -1
View File
@@ -362,6 +362,11 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]),
("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]),
("Deselect All", ["Auswahl aufheben", "Tout désélectionner", "Deseleccionar todo", "すべての選択を解除", "取消全选"]),
(
"Select All Guides",
["Alle Hilfslinien auswählen", "Sélectionner tous les repères", "Seleccionar todas las guías", "すべてのガイドを選択", "选择所有参考线"],
),
("Ruler Guide", ["Hilfslinie", "Repère de règle", "Guía de regla", "定規ガイド", "标尺参考线"]),
("Find/Change…", ["Suchen/Ersetzen …", "Rechercher/Remplacer…", "Buscar/Cambiar…", "検索と置換…", "查找/更改…"]),
("Check Spelling…", ["Rechtschreibprüfung …", "Vérifier l'orthographe…", "Revisar ortografía…", "スペルチェック…", "拼写检查…"]),
("Preferences…", ["Voreinstellungen …", "Préférences…", "Preferencias…", "環境設定…", "首选项…"]),
@@ -3199,10 +3204,14 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Graphic…", ["Graphic…", "Graphique…", "Graphic…", "Graphic…", "图形…"]),
("Home", ["Home", "Accueil", "Home", "Home", "主页"]),
("Join:", ["Join:", "Sommet :", "Join:", "Join:", "连接:"]),
("Language", ["Language", "Langue", "Language", "Language", "语言"]),
("Language", ["Sprache", "Langue", "Idioma", "言語", "语言"]),
("Last Spread", ["Last Spread", "Dernière planche", "Last Spread", "Last Spread", "最后一个跨页"]),
("Load DTD…", ["Load DTD…", "Charger la DTD…", "Load DTD…", "Load DTD…", "载入 DTD…"]),
("Location:", ["Location:", "Emplacement :", "Location:", "Location:", "位置:"]),
(
"Lock Column Guides",
["Spaltenhilfslinien sperren", "Verrouiller les repères de colonne", "Bloquear guías de columna", "列ガイドをロック", "锁定栏参考线"],
),
("Lock Guides", ["Lock Guides", "Verrouiller les repères", "Lock Guides", "Lock Guides", "锁定参考线"]),
("Miter Limit:", ["Miter Limit:", "Limite de pointe :", "Miter Limit:", "Miter Limit:", "斜接限制:"]),
(
+3
View File
@@ -158,6 +158,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "تكرار"),
("Select All", "تحديد الكل"),
("Deselect All", "إلغاء تحديد الكل"),
("Select All Guides", "تحديد كل الأدلة"),
("Ruler Guide", "دليل المسطرة"),
("Find/Change…", "البحث/التغيير…"),
("Find/Change", "البحث/التغيير"),
("Check Spelling…", "تدقيق إملائي…"),
@@ -404,6 +406,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Adjust Layout", "ضبط التخطيط"),
("Smart Guides", "أدلة ذكية"),
("Lock Guides", "قفل الأدلة"),
("Lock Column Guides", "قفل أدلة الأعمدة"),
("Show Document Grid", "إظهار شبكة المستند"),
("Show Baseline Grid", "إظهار شبكة خطوط الأساس"),
("Options", "خيارات"),
+3
View File
@@ -48,6 +48,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Duplikovat"),
("Select All", "Vybrat vše"),
("Deselect All", "Zrušit výběr"),
("Select All Guides", "Vybrat všechna vodítka"),
("Ruler Guide", "Vodítko pravítka"),
("Find/Change…", "Najít/Změnit…"),
("Check Spelling…", "Zkontrolovat pravopis…"),
("Preferences…", "Předvolby…"),
@@ -1104,6 +1106,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Načíst DTD…"),
("Location:", "Umístění:"),
("Lock Guides", "Zamknout vodítka"),
("Lock Column Guides", "Zamknout vodítka sloupců"),
("Miter Limit:", "Limit ostrého spoje:"),
("New Article from Selection", "Nový článek z výběru"),
("New Note at Cursor", "Nová poznámka u kurzoru"),
+3
View File
@@ -64,6 +64,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Duplizieren"),
("Select All", "Alles auswählen"),
("Deselect All", "Auswahl aufheben"),
("Select All Guides", "Alle Hilfslinien auswählen"),
("Ruler Guide", "Hilfslinie"),
("Find/Change…", "Suchen/Ersetzen …"),
("Check Spelling…", "Rechtschreibprüfung …"),
("Preferences…", "Voreinstellungen …"),
@@ -1151,6 +1153,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "DTD laden …"),
("Location:", "Position:"),
("Lock Guides", "Hilfslinien sperren"),
("Lock Column Guides", "Spaltenhilfslinien sperren"),
("Miter Limit:", "Gehrungsgrenze:"),
("New Article from Selection", "Neuer Artikel aus Auswahl"),
("New Note at Cursor", "Neue Notiz an der Einfügemarke"),
+3
View File
@@ -154,6 +154,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Duplica"),
("Select All", "Seleziona tutto"),
("Deselect All", "Deseleziona tutto"),
("Select All Guides", "Seleziona tutte le guide"),
("Ruler Guide", "Guida del righello"),
("Find/Change…", "Trova/Sostituisci…"),
("Check Spelling…", "Controllo ortografico…"),
("Preferences…", "Preferenze…"),
@@ -1244,6 +1246,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Carica DTD…"),
("Location:", "Posizione:"),
("Lock Guides", "Blocca guide"),
("Lock Column Guides", "Blocca guide colonne"),
("Miter Limit:", "Limite spigolo:"),
("New Article from Selection", "Nuovo articolo dalla selezione"),
("New Note at Cursor", "Nuova nota al cursore"),
+3
View File
@@ -145,6 +145,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "複製"),
("Select All", "すべてを選択"),
("Deselect All", "すべての選択を解除"),
("Select All Guides", "すべてのガイドを選択"),
("Ruler Guide", "定規ガイド"),
("Find/Change…", "検索と置換…"),
("Check Spelling…", "スペルチェック…"),
("Preferences…", "環境設定…"),
@@ -1349,6 +1351,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "DTD を読み込み…"),
("Location:", "位置:"),
("Lock Guides", "ガイドをロック"),
("Lock Column Guides", "列ガイドをロック"),
("Miter Limit:", "マイターの上限:"),
("New Article from Selection", "選択範囲から新規アーティクルを作成"),
("New Note at Cursor", "カーソル位置に新規注釈"),
+3
View File
@@ -77,6 +77,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "დუბლირება"),
("Select All", "ყველაფრის მონიშვნა"),
("Deselect All", "ყველა მონიშვნის მოხსნა"),
("Select All Guides", "ყველა დამხმარე ხაზის მონიშვნა"),
("Ruler Guide", "სახაზავის დამხმარე ხაზი"),
("Find/Change…", "ძებნა/ჩანაცვლება…"),
("Check Spelling…", "მართლწერის შემოწმება…"),
("Preferences…", "პარამეტრები…"),
@@ -1181,6 +1183,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "DTD-ის ჩატვირთვა…"),
("Location:", "მდებარეობა:"),
("Lock Guides", "დამხმარე ხაზების ჩაკეტვა"),
("Lock Column Guides", "სვეტების დამხმარე ხაზების ჩაკეტვა"),
("Miter Limit:", "მახვილის ზღვარი:"),
("New Article from Selection", "ახალი სტატია მონიშნულიდან"),
("New Note at Cursor", "ახალი შენიშვნა კურსორთან"),
+3
View File
@@ -44,6 +44,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Powiel"),
("Select All", "Zaznacz wszystko"),
("Deselect All", "Usuń zaznaczenie"),
("Select All Guides", "Zaznacz wszystkie linie pomocnicze"),
("Ruler Guide", "Linia pomocnicza"),
("Find/Change…", "Znajdź/zmień…"),
("Check Spelling…", "Sprawdź pisownię…"),
("Preferences…", "Preferencje…"),
@@ -1114,6 +1116,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Wczytaj DTD…"),
("Location:", "Położenie:"),
("Lock Guides", "Zablokuj linie pomocnicze"),
("Lock Column Guides", "Zablokuj linie pomocnicze łamów"),
("Miter Limit:", "Limit ostrych narożników:"),
("New Article from Selection", "Nowy artykuł z zaznaczenia"),
("New Note at Cursor", "Nowa notatka przy kursorze"),
+3
View File
@@ -165,6 +165,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Duplicar"),
("Select All", "Selecionar tudo"),
("Deselect All", "Desmarcar tudo"),
("Select All Guides", "Selecionar todas as guias"),
("Ruler Guide", "Guia da régua"),
("Find/Change…", "Localizar/alterar…"),
("Find/Change", "Localizar/alterar"),
("Check Spelling…", "Verificar ortografia…"),
@@ -412,6 +414,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Adjust Layout", "Ajustar layout"),
("Smart Guides", "Guias inteligentes"),
("Lock Guides", "Bloquear guias"),
("Lock Column Guides", "Bloquear guias de colunas"),
("Show Document Grid", "Mostrar grade do documento"),
("Show Baseline Grid", "Mostrar grade de linha de base"),
("Options", "Opções"),
+3
View File
@@ -48,6 +48,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Дублировать"),
("Select All", "Выделить все"),
("Deselect All", "Снять выделение"),
("Select All Guides", "Выделить все направляющие"),
("Ruler Guide", "Направляющая"),
("Find/Change…", "Найти/заменить…"),
("Check Spelling…", "Проверка орфографии…"),
("Preferences…", "Установки…"),
@@ -1091,6 +1093,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Загрузить DTD…"),
("Location:", "Расположение:"),
("Lock Guides", "Заблокировать направляющие"),
("Lock Column Guides", "Заблокировать направляющие колонок"),
("Miter Limit:", "Ограничение острого угла:"),
("New Article from Selection", "Новая статья из выделенного"),
("New Note at Cursor", "Новая заметка в позиции курсора"),
+3
View File
@@ -157,6 +157,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Дублювати"),
("Select All", "Виділити все"),
("Deselect All", "Зняти всі виділення"),
("Select All Guides", "Виділити всі напрямні"),
("Ruler Guide", "Напрямна"),
("Find/Change…", "Знайти й замінити…"),
("Check Spelling…", "Перевірити орфографію…"),
("Preferences…", "Налаштування…"),
@@ -1241,6 +1243,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Завантажити DTD…"),
("Location:", "Розташування:"),
("Lock Guides", "Заблокувати напрямні"),
("Lock Column Guides", "Заблокувати напрямні колонок"),
("Miter Limit:", "Межа гострого з’єднання:"),
("New Article from Selection", "Нова стаття з виділення"),
("New Note at Cursor", "Нова примітка в позиції курсора"),
+2
View File
@@ -296,6 +296,7 @@ pub struct UiState {
pub align_to: String,
/// Properties > Text Style tab: 0 = Paragraph Styles, 1 = Character Styles.
pub text_style_tab: u8,
/// View › Grids & Guides › Lock Guides: ruler guides can't be dragged.
pub guides_locked: bool,
pub smart_guides: bool,
#[serde(default = "default_true")]
@@ -457,6 +458,7 @@ impl UiState {
smart_dimensions: self.smart_dimensions,
smart_spacing: self.smart_spacing,
zone_px: self.snap_zone,
guides_locked: self.guides_locked,
}
}
}
+85 -14
View File
@@ -180,6 +180,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"),
("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"),
("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"),
("view.lockGuides", "Lock Guides", Some("Cmd+Alt+;"), "{on?: bool} — ruler guides can't be dragged; toggles without `on`"),
("view.snapToGuides", "Snap to Guides", Some("Cmd+Shift+;"), "{}"),
("view.snapToDocumentGrid", "Snap to Document Grid", None, "{}"),
("view.smartGuides", "Smart Guides", None, "{}"),
@@ -366,6 +367,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:edit.stepAndRepeat",
"-",
"cmd:edit.selectAll",
"cmd:guide.selectAll",
"cmd:edit.deselectAll",
"-",
"ui:app.findChange",
@@ -811,6 +813,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
"<",
">Grids & Guides",
"ui:view.guides",
"ui:view.lockGuides",
"cmd:view.lockColumnGuides",
"ui:view.snapToGuides",
"ui:view.snapToDocumentGrid",
"ui:view.smartGuides",
@@ -1320,16 +1324,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
Ok(Value::Null)
}
"app.deleteAllGuides" => {
let Some(st) = app.session.active() else { return Some(Err("no document open".into())) };
let page = crate::canvas::current_page(app).unwrap_or(0);
let spread = if st.editing_parents {
// The parent spread the canvas shows.
let layout = designcraft_tools::CanvasLayout::new(&st.doc, true);
let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread);
json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0)))
} else {
json!(st.doc.page_loc(page).map_or(0, |l| l.0))
};
let Some(spread) = guide_spread(app) else { return Some(Err("no document open".into())) };
return Some(app.run("guide.deleteAll", json!({"spread": spread})));
}
"app.deletePage" | "app.duplicateSpread" => {
@@ -1513,6 +1508,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
}
"view.rulers" => flag(&mut app.ui.rulers),
"view.guides" => flag(&mut app.ui.guides),
"view.lockGuides" => {
app.ui.guides_locked = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.guides_locked);
Ok(json!(app.ui.guides_locked))
}
"view.snapToGuides" => flag(&mut app.ui.snap_to_guides),
"view.snapToDocumentGrid" => flag(&mut app.ui.snap_to_document_grid),
"view.smartGuides" => flag(&mut app.ui.smart_guides),
@@ -2286,6 +2285,9 @@ pub enum AppMenuItem {
command: &'static str,
},
Sep,
/// Interface language: a submenu with each UI language in its own name, the current one
/// checked (`app.language {lang}`).
Language,
Services,
Hide,
HideOthers,
@@ -2298,6 +2300,7 @@ pub const MAC_APP_MENU: &[AppMenuItem] = &[
AppMenuItem::Cmd { id: "dc-discord", label: "Join the ArtCraft Discord…", command: "help.discord" },
AppMenuItem::Sep,
AppMenuItem::Cmd { id: "dc-settings", label: "Settings…", command: "app.preferences" },
AppMenuItem::Language,
AppMenuItem::Sep,
AppMenuItem::Services,
AppMenuItem::Sep,
@@ -2368,6 +2371,8 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
"view.typicalDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::Typical,
"view.highQualityDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::High,
"view.guides" => app.ui.guides,
"view.lockGuides" => app.ui.guides_locked,
"view.lockColumnGuides" => app.session.active().is_some_and(|d| d.doc.settings.column_guides_locked),
"view.snapToGuides" => app.ui.snap_to_guides,
"view.snapToDocumentGrid" => app.ui.snap_to_document_grid,
"view.smartGuides" => app.ui.smart_guides,
@@ -2439,6 +2444,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
if id.starts_with("fit.") {
return enabled(app, "object.fit");
}
if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
return false;
}
if ui_label(id).is_some() {
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
}
@@ -2540,6 +2548,16 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) {
app.ui.dialog = Some(crate::dialogs::Dialog::new(&format!("cmd:{id}"), json!({})));
return;
}
// Select All Guides: on the spread in view, while guides are shown and not locked.
if params.is_null() && id == "guide.selectAll" {
if app.ui.guides
&& !app.ui.guides_locked
&& let Some(spread) = guide_spread(app)
{
let _ = app.run(id, json!({"spread": spread}));
}
return;
}
let p = if params.is_null() { json!({}) } else { params.clone() };
// Update Link asks before replacing edits made to linked text in the layout.
if id == "links.update" {
@@ -2549,6 +2567,20 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) {
let _ = app.run(id, p);
}
/// The spread the guide commands of the menus act on: the one in view (a parent spread while
/// parents are edited).
fn guide_spread(app: &DesignApp) -> Option<Value> {
let st = app.session.active()?;
let page = crate::canvas::current_page(app).unwrap_or(0);
Some(if st.editing_parents {
let layout = designcraft_tools::CanvasLayout::new(&st.doc, true);
let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread);
json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0)))
} else {
json!(st.doc.page_loc(page).map_or(0, |l| l.0))
})
}
/// A menu button whose popup stays within the viewport and scrolls when needed.
/// All menu buttons share this wrapper, including workspace, zoom and panel menus.
pub fn menu_button<'a, R>(
@@ -3081,9 +3113,10 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
}
}
if let Some((id, exact)) = fired {
// The native menu's accelerator fires only for the shortcut's own key and modifiers; the
// Plus key standing in for = never reaches it.
if exact && app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
// The native menu bar carries the shortcuts in effect, the user's included, and its
// accelerator fires only for the shortcut's own key and modifiers; the Plus key standing
// in for = never reaches it.
if exact && app.native_shortcuts.contains(&id) {
continue; // The native menu handles it.
}
// The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
@@ -3945,6 +3978,26 @@ mod tests {
assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err());
}
#[test]
fn lock_guides_and_lock_column_guides_are_checked_in_grids_and_guides() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
let view = menu_ids("View");
assert!(view.contains(&"view.lockGuides") && view.contains(&"view.lockColumnGuides"));
assert_eq!(ui_label("view.lockGuides").and_then(|c| c.1), Some("Cmd+Alt+;"));
// Lock Guides: off at first, toggles, and `on` sets it.
assert_eq!(checked(&app, "view.lockGuides", &Value::Null), Some(false));
assert_eq!(app.run("view.lockGuides", json!({})).unwrap(), json!(true));
assert!(app.ui.guides_locked);
assert_eq!(app.run("view.lockGuides", json!({"on": true})).unwrap(), json!(true));
assert_eq!(checked(&app, "view.lockGuides", &Value::Null), Some(true));
assert_eq!(app.run("view.lockGuides", json!({"on": false})).unwrap(), json!(false));
// Lock Column Guides: the document's setting, on in new documents.
assert_eq!(checked(&app, "view.lockColumnGuides", &Value::Null), Some(true));
app.run("view.lockColumnGuides", json!({})).unwrap();
assert_eq!(checked(&app, "view.lockColumnGuides", &Value::Null), Some(false));
}
#[test]
fn fitting_has_indesigns_shortcuts() {
// LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the
@@ -4506,6 +4559,19 @@ mod tests {
let z = app.view().unwrap().zoom;
assert_eq!(z > z0, zooms_in, "native menu, {k:?} {m:?}: {z0} -> {z}");
}
// A shortcut the user set runs here until the native menu bar carries it as its key
// equivalent; then only the bar runs it, once.
run_ui(&mut app, "window.setShortcut", &json!({"id": "view.zoomOut", "shortcut": "Cmd+9"})).unwrap().unwrap();
for (native, zooms_out) in [(false, true), (true, false)] {
if native {
app.native_shortcuts.insert("view.zoomOut".to_string());
}
let z0 = app.view().unwrap().zoom;
frame(&mut app, key(egui::Key::Num9, egui::Modifiers::COMMAND));
frame(&mut app, vec![]);
let z = app.view().unwrap().zoom;
assert_eq!(z < z0, zooms_out, "native {native}: {z0} -> {z}");
}
}
#[test]
@@ -5552,7 +5618,12 @@ mod tests {
let (Some(about), Some(settings)) = (pos("help.about"), pos("app.preferences")) else { panic!("About and Settings are in the app menu") };
assert!(about < settings);
assert_eq!(MAC_APP_MENU.get(settings - 1), Some(&AppMenuItem::Sep));
assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Sep));
// Language follows Settings, as in the suite's other apps, titled in the interface language.
assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language));
assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep));
for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
assert_ne!(crate::i18n::tr(code, "Language"), "Language", "{code}");
}
let label = |command: &str| {
MAC_APP_MENU.iter().find_map(|m| match m {
AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label),
+12 -3
View File
@@ -64,6 +64,8 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) {
let selected = info.is_some();
let reference = app.ui.ref_point.min(8);
let geometry = info.as_ref().map(|i| i.page_rect);
// One ruler guide selected: X (vertical guide) or Y (horizontal guide) is where it is.
let guide = panels::guide_field(app);
group(ui, "reference", 26.0, |ui, rect| {
let proxy = Rect::from_center_size(rect.center(), vec2(26.0, 26.0));
if let Some(point) = properties::ref_point_proxy(ui, proxy, reference) {
@@ -81,10 +83,17 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) {
for (column, (label, id, key, index)) in fields.into_iter().enumerate() {
let x = column as f32 * 108.0;
cell(ui, rect, x, row, 14.0, true, |ui| widgets::caption_w(ui, label, 14.0));
let value = values.map(|v| v[index]);
if let Some(v) = cell(ui, rect, x + 15.0, row, 86.0, selected, |ui| {
NumField::measure(id, value, units).width(86.0).spinner().enabled(selected).show(ui)
let on_guide = guide.filter(|g| g.row() == index);
let value = if guide.is_some() { on_guide.map(|g| g.value) } else { values.map(|v| v[index]) };
let enabled = selected || on_guide.is_some();
let unit = on_guide.map_or(units, |g| g.unit);
if let Some(v) = cell(ui, rect, x + 15.0, row, 86.0, enabled, |ui| {
NumField::measure(id, value, unit).width(86.0).spinner().enabled(enabled).show(ui)
}) {
if let Some(g) = on_guide {
let _ = app.run("guide.move", g.move_to(v));
continue;
}
set.insert(key.into(), json!(v));
if constrain
&& column == 1
+43
View File
@@ -117,6 +117,49 @@ pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 {
st.selection.items.first().and_then(|id| st.doc.item(*id)).map_or(1.0, |it| if stroke { it.stroke.tint } else { it.fill.tint })
}
/// The one selected ruler guide, as the X field (vertical guide) or Y field (horizontal guide)
/// of the Control and Properties panels shows it: measured as the rulers measure, from the zero
/// point (and the page, when rulers restart per page), in that ruler's units.
#[derive(Clone, Copy, Debug)]
pub struct GuideField {
pub guide: designcraft_doc::GuideSel,
pub orientation: designcraft_doc::Orientation,
/// Points from the ruler origin.
pub value: f64,
/// Where `value` 0 is: a spread x for a vertical guide, a y for a horizontal one.
pub origin: f64,
pub unit: designcraft_geom::Unit,
}
impl GuideField {
/// Which field shows it: 0 for X, 1 for Y.
pub fn row(&self) -> usize {
match self.orientation {
designcraft_doc::Orientation::Vertical => 0,
designcraft_doc::Orientation::Horizontal => 1,
}
}
/// The `guide.move` params that put it at `value` (as the field reads).
pub fn move_to(&self, value: f64) -> Value {
let g = self.guide;
json!({"spread": g.spread, "page": g.page, "index": g.index, "position": value + self.origin})
}
}
/// [`GuideField`] when exactly one ruler guide is selected.
pub fn guide_field(app: &DesignApp) -> Option<GuideField> {
let st = app.session.active()?;
let [g] = st.selection.guides.as_slice() else { return None };
let pg = st.doc.spread(g.spread)?.pages.get(g.page)?;
let guide = pg.guides.get(g.index)?;
let (origin, unit) = match guide.orientation {
designcraft_doc::Orientation::Vertical => (st.doc.ruler_origin(g.spread, guide.position)?.x, st.doc.settings.horizontal_units),
designcraft_doc::Orientation::Horizontal => (st.doc.ruler_origin(g.spread, pg.x + pg.width / 2.0)?.y, st.doc.settings.vertical_units),
};
let value = guide.position - origin;
value.is_finite().then_some(GuideField { guide: *g, orientation: guide.orientation, value, origin, unit })
}
pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
let st = app.session.active()?;
if st.selection.items.is_empty() {
+32 -1
View File
@@ -59,6 +59,9 @@ fn selection_title(app: &DesignApp, info: &Option<SelInfo>) -> String {
if st.selection.text.is_some() {
return crate::i18n::tr(&app.ui.language, "Characters").into();
}
if info.is_none() && !st.selection.guides.is_empty() {
return crate::i18n::tr(&app.ui.language, "Ruler Guide").into();
}
match info {
None => crate::i18n::tr(&app.ui.language, "No Selection").into(),
Some(i) if i.count > 1 => crate::i18n::tr(&app.ui.language, "Multiple Objects").into(),
@@ -106,6 +109,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
quick_actions_text(app, ui);
return;
}
if info.is_none() && app.session.active().is_some_and(|st| !st.selection.guides.is_empty()) {
section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true);
guide_section(app, ui);
return;
}
// The Page tool with no objects selected: the page's own settings, as InDesign's.
if info.is_none() && app.session.tool_id() == "page" {
let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app));
@@ -513,7 +521,9 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) {
if smart != app.ui.smart_guides {
let _ = app.run("view.smartGuides", json!({}));
}
app.ui.guides_locked = locked;
if locked != app.ui.guides_locked {
let _ = app.run("view.lockGuides", json!({"on": locked}));
}
rule(ui);
section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true);
let (a, b) = button_pair(ui, crate::i18n::tr(&app.ui.language, "Import File"), crate::i18n::tr(&app.ui.language, "Insert Page"));
@@ -613,6 +623,27 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
}
}
/// Properties › Transform for selected ruler guides: X for one vertical guide, Y for one
/// horizontal guide; with several guides both fields are empty.
fn guide_section(app: &mut DesignApp, ui: &mut Ui) {
let guide = super::guide_field(app);
let b = block(ui, 2.0 * FIELD_H + 4.0);
for (row, (label, key)) in [("X:", "gx"), ("Y:", "gy")].into_iter().enumerate() {
let yy = row as f32 * 25.0;
let on = guide.filter(|g| g.row() == row);
place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, label, 18.0));
let typed = place(ui, sub(b, 59.0, yy, 135.0, FIELD_H), |ui| {
if on.is_none() {
ui.disable();
}
measure(ui, key, on.map(|g| g.value), on.map_or_else(|| units(app), |g| g.unit), 135.0)
});
if let (Some(v), Some(g)) = (typed, on) {
let _ = app.run("guide.move", g.move_to(v));
}
}
}
/// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the
/// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip.
fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) {
+2 -2
View File
@@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends.
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer).
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
@@ -283,7 +283,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself.
+1 -1
View File
@@ -148,7 +148,7 @@ if has appimage; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
download="$TOOL.download"
curl -fsSL -o "$download" \
curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c -
chmod 755 "$download"
+2
View File
@@ -0,0 +1,2 @@
sonar.projectKey=storytold_designcraft
sonar.organization=storytold