diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index 01b3ca6d4b..907117b0c4 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -758,583 +758,4 @@ fn restart(skip: &[String], startup: &Startup) -> std::io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { - Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } - } - - fn names(c: &[Candidate], order: &[usize]) -> Vec { - order.iter().map(|&i| c[i].name.clone()).collect() - } - - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - const WARP: (u32, u32) = (0x1414, 0x008c); - - fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { - Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } - } - - fn monitor(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: false } - } - - fn primary(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: true } - } - - /// A Windows desktop as wgpu lists it with every backend: a Ryzen's integrated GPU without a - /// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and - /// OpenGL (which reports no device id). - fn ryzen_desktop() -> Vec { - vec![ - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), - pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), - ] - } - - /// Power saving picked the integrated GPU, whose driver reset when Windows had to present its - /// frames on the monitors of the NVIDIA card. The GPU that drives the displays comes first, - /// through DX12; the integrated GPU stays the fallback a restart reaches. - #[test] - fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { - let c = ryzen_desktop(); - for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - } - // Without the display information the order is power saving's. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]); - // After the NVIDIA adapters failed, the integrated GPU is next. - let skip = vec![c[2].key.clone(), c[0].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]); - } - - /// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the - /// discrete one. Power saving and the display agree, in either enumeration order. - #[test] - fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { - let c = ryzen_desktop(); - for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); - } - // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the - // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one - // comes after both. - let linux = vec![ - pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), - pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), - ]; - let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; - assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]); - } - - /// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so - /// its GPU draws, whichever kind it is. - #[test] - fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { - let c = ryzen_desktop(); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]); - // The displays only matter among adapters that can show the window; a GPU driving no - // display still loses to one that does. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]); - } - - /// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, - /// Metal's zeros) change nothing: power saving's order. - #[test] - fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { - let c = ryzen_desktop(); - let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}"); - } - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090"); - // The Wayland desktop's adapters report no ids in these tests: the primary display never matches. - let c = wayland_desktop(); - assert_eq!( - adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]), - adapter_order(&c, PowerPreference::None, &[], None, &[]) - ); - assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty()); - } - - /// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. - #[test] - fn a_named_adapter_comes_before_the_one_driving_the_display() { - let mut c = ryzen_desktop(); - let displays = [primary(NVIDIA_3090)]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); - c[2].presents = false; - c[0].presents = false; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]); - } - - /// `WGPU_POWER_PREF`, the user's own choice, is kept as it is; only without it do the displays - /// count. - #[test] - fn only_automatic_considers_which_gpu_drives_the_display() { - assert!(automatic(None)); - assert!(!automatic(Some(PowerPreference::None))); - assert!(!automatic(Some(PowerPreference::LowPower))); - assert!(preferred_displays(Some(PowerPreference::HighPerformance)).is_empty()); - // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). - assert_eq!(preferred_displays(None), display_gpus()); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(pci_ids("VEN_&DEV_"), None); - assert_eq!(pci_ids("PCI\\VEN_10DÉ&DEV_2204"), None); - assert_eq!(pci_ids(""), None); - } - - /// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary - /// display, and none is listed twice. (A headless CI runner may list none.) - #[cfg(windows)] - #[test] - fn windows_lists_each_gpu_with_a_monitor_once() { - let gpus = windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert_ne!(g.pci.0, 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!is_internal_panel(name), "{name}"); - } - } - - /// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port - /// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without - /// PCI ids. Each GPU is listed once, the one with the panel as primary. - #[test] - fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("designcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card0", AMD_IGPU)?; - card("card1", NVIDIA_3090)?; - std::fs::create_dir_all(dir.join("card2"))?; - connector("card0-eDP-1", "connected")?; - connector("card0-HDMI-A-1", "disconnected")?; - connector("card1-DP-1", "connected")?; - connector("card1-DP-2", "connected")?; - connector("card2-Virtual-1", "connected")?; - std::fs::write(dir.join("renderD128"), "")?; - let mut gpus = linux_display_gpus(&dir); - gpus.sort_by_key(|g| g.pci); - assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); - assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); - std::fs::remove_dir_all(&dir) - } - - #[test] - fn display_gpus_are_logged_as_pci_ids() { - assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); - assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); - let mut gpus = vec![]; - add_display_gpu(&mut gpus, NVIDIA_3090, false); - add_display_gpu(&mut gpus, NVIDIA_3090, true); - add_display_gpu(&mut gpus, AMD_IGPU, false); - assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); - } - - /// A dual-GPU Linux desktop as Vulkan lists it with Mesa's device-select layer: the GPU KDE's - /// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software - /// renderer, and OpenGL. - fn wayland_desktop() -> Vec { - vec![ - gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), - gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), - ] - } - - #[test] - fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { - let c = wayland_desktop(); - let order = adapter_order(&c, power_preference(None), &[], None, &[]); - let first = &c[order[0]].name; - if cfg!(any(windows, target_os = "macos")) { - assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - } else { - // The integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. - assert_eq!(first, "NVIDIA GeForce RTX 5070"); - } - } - - #[test] - fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { - let c = wayland_desktop(); - let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[])); - assert_eq!( - order(PowerPreference::None), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::LowPower), - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::HighPerformance), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - /// A restart leaves out the adapter the window failed on, and once every adapter failed there - /// is nothing to try. A dual-GPU Mac reports both GPUs under Metal with no PCI ids: their keys - /// still differ, so when the integrated GPU fails, the restart renders on the discrete one. - #[test] - fn gpus_without_pci_ids_are_told_apart_by_name() { - let mac = |name: &str, device_type| Candidate { - key: key(Backend::Metal, 0, 0, name), - name: name.into(), - backend: Backend::Metal, - device_type, - pci: (0, 0), - presents: true, - }; - let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; - assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); - let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); - let skip = parse_skip(&c[first[0]].key); - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]); - // A comma in a name can't split the list. - assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); - } - - fn sample(frames: u64, busy: bool, leaves: u64, hidden: bool) -> Sample { - Sample { frames, busy, leaves, hidden } - } - - /// Runs `ticks` looks with the same sample; the verdicts other than `Wait`. - fn run(w: &mut Watch, s: Sample, ticks: u32) -> Vec<(u32, Verdict)> { - (1..=ticks).filter_map(|t| Some((t, w.step(s, WATCH_TICK))).filter(|(_, v)| *v != Verdict::Wait)).collect() - } - - const TICKS_PER_S: u32 = 4; - - /// The UI thread stuck in the graphics stack (not in the app's code, no progress) with the - /// window not known to be hidden: late after 20 s, hung after 60 s. - #[test] - fn a_ui_thread_stuck_in_the_graphics_stack_hangs_its_adapter() { - let mut w = Watch::default(); - let v = run(&mut w, sample(1, false, 1, false), 61 * TICKS_PER_S); - assert_eq!(v.first(), Some(&(20 * TICKS_PER_S, Verdict::Late))); - // `leaves` moved from 0 to 1 on the first look, so the stuck time starts one look later. - assert_eq!(v.get(1), Some(&(60 * TICKS_PER_S + 1, Verdict::Hung))); - // The second frame began: the first was presented. - assert_eq!(Watch::default().step(sample(2, false, 9, false), WATCH_TICK), Verdict::Shown); - } - - /// A busy UI thread (a large document composing on a slow machine) is only ever late. - #[test] - fn a_busy_ui_thread_never_hangs_its_adapter() { - let mut w = Watch::default(); - let v = run(&mut w, sample(0, true, 0, false), 600 * TICKS_PER_S); - assert_eq!(v, [(20 * TICKS_PER_S, Verdict::Late)]); - // Nor does one that keeps coming back from the graphics stack. - let mut w = Watch::default(); - for t in 0..(600 * TICKS_PER_S) { - assert_ne!(w.step(sample(1, false, u64::from(t), false), WATCH_TICK), Verdict::Hung); - } - } - - /// A window known to be hidden (locked screen, another workspace, minimized) may have its frames - /// held back by the compositor: that time counts for nothing and starts the stuck time over. - #[test] - fn time_while_the_window_is_hidden_does_not_count() { - let mut w = Watch::default(); - assert!(run(&mut w, sample(1, false, 0, true), 600 * TICKS_PER_S).is_empty()); - assert!(run(&mut w, sample(1, false, 0, false), 50 * TICKS_PER_S).iter().all(|(_, v)| *v == Verdict::Late)); - assert!(run(&mut w, sample(1, false, 0, true), 10 * TICKS_PER_S).is_empty()); - assert!(run(&mut w, sample(1, false, 0, false), 59 * TICKS_PER_S).is_empty()); - assert_eq!(run(&mut w, sample(1, false, 0, false), TICKS_PER_S), [(TICKS_PER_S, Verdict::Hung)]); - } - - #[test] - fn a_hung_adapter_is_left_out_once() { - let skip = vec!["Dx12:10de:28a0".to_string()]; - assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); - assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); - } - - /// Windows, a hardware DX12 adapter failing: the software renderer (WARP) is passed over while - /// another backend is left, the restarts go through OpenGL and Vulkan, and only then back to - /// every backend with software allowed. The chain ends. - #[test] - fn software_renderers_come_after_every_backends_hardware() { - use backend::Backend as B; - let c = ryzen_desktop(); - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - assert_eq!(without_software(order.clone(), &c, false), (order.clone(), false)); - let (hardware, dropped) = without_software(order, &c, true); - assert!(dropped && !hardware.contains(&4), "{hardware:?}"); - - let dx12 = "Dx12:10de:2204".to_string(); - // The adapter that failed first. - let (skip, _) = next_start(&[], Some(&dx12), Some((B::Dx12, Some(B::Gl))), true).unwrap(); - assert_eq!(skip, [dx12.as_str()]); - // Only WARP left on DX12 (passed over, nothing picked): OpenGL next. - let (skip, what) = next_start(&skip, None, Some((B::Dx12, Some(B::Gl))), true).unwrap(); - assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT]); - assert!(what.contains("OpenGL"), "{what}"); - // OpenGL has no adapter, Vulkan next; then Vulkan has none and no backend is left. - let (skip, _) = next_start(&skip, None, Some((B::Gl, Some(B::Vulkan))), false).unwrap(); - assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT, "Gl"]); - let (skip, what) = next_start(&skip, None, Some((B::Vulkan, None)), false).unwrap(); - assert_eq!(skip, [dx12.as_str(), SOFTWARE], "{what}"); - assert!(backend::skipped_backends(&skip).is_empty()); - // With software allowed and nothing left: the end. - assert_eq!(next_start(&skip, None, Some((B::Dx12, None)), false), None); - // Nothing was passed over (a Mac whose GPUs all failed): no software round. - assert_eq!(next_start(&[], None, Some((B::Metal, None)), false), None); - // Without the backend fallback (`WGPU_BACKEND`), only adapters. - assert_eq!(next_start(&[], None, None, false), None); - } - - /// The skip list travels as an argument, which processes the app starts don't inherit. - #[test] - fn the_skip_list_is_an_argument() { - assert_eq!(skip_arg("--gpu-skip=Dx12:10de:2204,Dx12"), Some(vec!["Dx12:10de:2204".to_string(), "Dx12".to_string()])); - assert_eq!(skip_arg("--gpu-skip="), Some(vec![])); - assert_eq!(skip_arg("--gpu-skip"), None); - assert_eq!(skip_arg("layout.designcraft"), None); - } - - #[test] - fn a_restart_tries_the_next_adapter() { - let c = wayland_desktop(); - let mut skip = vec![]; - let mut tried = vec![]; - while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() { - tried.push(c[i].name.clone()); - skip.push(c[i].key.clone()); - } - assert_eq!( - tried, - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - #[test] - fn adapters_that_cannot_present_to_the_window_are_never_tried() { - let mut c = wayland_desktop(); - c[0].presents = false; - c[3].presents = false; - for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { - let order = adapter_order(&c, power, &[], None, &[]); - assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); - assert_eq!(order.len(), 2); - } - for p in &mut c { - p.presents = false; - } - assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty()); - assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty()); - } - - #[test] - fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { - let c = wayland_desktop(); - let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]); - assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - assert_eq!(order.len(), 4); - // Any case; every match comes before the rest, natively first. - let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]); - assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); - // An empty name or one nothing matches changes nothing. - for named in ["", "no such gpu"] { - assert_eq!( - adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]), - adapter_order(&c, PowerPreference::LowPower, &[], None, &[]) - ); - } - // A named adapter that failed is left out like any other. - let skip = vec![c[1].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0); - } - - /// A Windows machine lists every GPU twice when Vulkan and DX12 are both in the instance: - /// power saving still takes the integrated GPU first, each GPU through DX12 first. - #[test] - fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { - let c = vec![ - gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - ]; - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - assert_eq!(order, [3, 1, 2, 0, 4]); - assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]); - } - - /// One Intel GPU whose Vulkan driver made the window flicker black: DX12 comes first whatever - /// the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` or a restart leaving DX12 - /// out still reach it. - #[test] - fn a_windows_gpu_renders_through_dx12_before_vulkan() { - let c = vec![ - gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), - ]; - for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}"); - } - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); - } - - #[test] - fn power_preference_follows_the_environment() { - assert_eq!(power_preference(None), AUTOMATIC); - for p in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(power_preference(Some(p)), p); - } - assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); - } - - /// The Intel Mac of #334 lost its Metal device compiling the compute shader of wgpu's indirect - /// call check: off unless `WGPU_VALIDATION_INDIRECT_CALL` turns it on. - #[test] - fn the_indirect_call_check_is_off() { - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - assert!(!instance_flags().contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); - } - // The other flags of the build stay as wgpu sets them. - let others = wgpu::InstanceFlags::from_build_config() - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL; - if std::env::vars_os().all(|(k, _)| !k.to_string_lossy().starts_with("WGPU_")) { - assert_eq!(instance_flags(), others); - } - } - - #[test] - fn skipped_adapters_parse_from_a_comma_separated_list() { - assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); - assert!(parse_skip("").is_empty()); - assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); - assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); - // A whole backend left out is its bare name; it never matches an adapter's key. - let skip = parse_skip("Dx12,Dx12:10de:2204"); - assert_eq!(backend::skipped_backends(&skip), [backend::Backend::Dx12]); - let c = vec![pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090)]; - assert!(adapter_order(&c, PowerPreference::None, &[], None, &skip).is_empty()); - assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, &skip[..1]), [0]); - } - - #[test] - fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { - for file in [ - "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", - r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", - "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", - "naga-30.0.0/src/back/spv/writer.rs", - ] { - assert!(in_graphics_stack(file), "{file}"); - } - for file in [ - "apps/designcraft/src/main.rs", - "/home/wgpu/designcraft/crates/ui-egui/src/lib.rs", - "egui-0.36.2/src/context.rs", - "winit-0.30.12/src/x.rs", - ] { - assert!(!in_graphics_stack(file), "{file}"); - } - } - - #[test] - fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { - let amd = Some("Vulkan:1002:164e"); - assert_eq!(restart_without(true, 0, amd, &[]), amd); - assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); - // After start-up the adapter has shown frames; a lost window is something else. - assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); - // A bug outside the graphics, or no adapter picked: nothing another adapter would change. - assert_eq!(restart_without(false, 0, amd, &[]), None); - assert_eq!(restart_without(true, 0, None, &[]), None); - // Never the same adapter twice, so restarts end. - assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); - } - - /// Without a backend fallback (`WGPU_BACKEND` set), a start whose graphics failed with no - /// adapter to leave out ends with the error and the hint; a bug ends with its message. - #[test] - fn a_normal_exit_or_another_failure_does_not_restart() { - let startup = Startup::new(None, Vec::new()); - assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); - // No adapter was picked (none can show the window): the error is returned, nothing restarts. - let none = - eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); - assert_eq!( - finish(Ok(Err(none)), &startup), - Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) - ); - let _ = startup.adapter.set("Vulkan:1002:164e".into()); - GRAPHICS_PANIC.store(false, Ordering::Relaxed); - assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); - // Nothing to record without a fallback. - startup.presented(); - startup.started_with(wgpu::Backend::Gl); - assert_eq!(startup.status_line(), None); - } -} +mod tests; diff --git a/apps/designcraft/src/gpu/tests.rs b/apps/designcraft/src/gpu/tests.rs new file mode 100644 index 0000000000..c7990aab0e --- /dev/null +++ b/apps/designcraft/src/gpu/tests.rs @@ -0,0 +1,571 @@ +use super::*; + +fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { + Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } +} + +fn names(c: &[Candidate], order: &[usize]) -> Vec { + order.iter().map(|&i| c[i].name.clone()).collect() +} + +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); +const WARP: (u32, u32) = (0x1414, 0x008c); + +fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { + Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } +} + +fn monitor(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: false } +} + +fn primary(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: true } +} + +/// A Windows desktop as wgpu lists it with every backend: a Ryzen's integrated GPU without a +/// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and +/// OpenGL (which reports no device id). +fn ryzen_desktop() -> Vec { + vec![ + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), + pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), + ] +} + +/// Power saving picked the integrated GPU, whose driver reset when Windows had to present its +/// frames on the monitors of the NVIDIA card. The GPU that drives the displays comes first, +/// through DX12; the integrated GPU stays the fallback a restart reaches. +#[test] +fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { + let c = ryzen_desktop(); + for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + } + // Without the display information the order is power saving's. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]); + // After the NVIDIA adapters failed, the integrated GPU is next. + let skip = vec![c[2].key.clone(), c[0].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]); +} + +/// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the +/// discrete one. Power saving and the display agree, in either enumeration order. +#[test] +fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { + let c = ryzen_desktop(); + for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); + } + // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the + // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one + // comes after both. + let linux = vec![ + pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), + pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), + ]; + let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; + assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]); +} + +/// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so +/// its GPU draws, whichever kind it is. +#[test] +fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { + let c = ryzen_desktop(); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]); + // The displays only matter among adapters that can show the window; a GPU driving no + // display still loses to one that does. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]); +} + +/// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, +/// Metal's zeros) change nothing: power saving's order. +#[test] +fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { + let c = ryzen_desktop(); + let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}"); + } + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090"); + // The Wayland desktop's adapters report no ids in these tests: the primary display never matches. + let c = wayland_desktop(); + assert_eq!( + adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]), + adapter_order(&c, PowerPreference::None, &[], None, &[]) + ); + assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty()); +} + +/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. +#[test] +fn a_named_adapter_comes_before_the_one_driving_the_display() { + let mut c = ryzen_desktop(); + let displays = [primary(NVIDIA_3090)]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); + c[2].presents = false; + c[0].presents = false; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]); +} + +/// `WGPU_POWER_PREF`, the user's own choice, is kept as it is; only without it do the displays +/// count. +#[test] +fn only_automatic_considers_which_gpu_drives_the_display() { + assert!(automatic(None)); + assert!(!automatic(Some(PowerPreference::None))); + assert!(!automatic(Some(PowerPreference::LowPower))); + assert!(preferred_displays(Some(PowerPreference::HighPerformance)).is_empty()); + // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). + assert_eq!(preferred_displays(None), display_gpus()); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(pci_ids("VEN_&DEV_"), None); + assert_eq!(pci_ids("PCI\\VEN_10DÉ&DEV_2204"), None); + assert_eq!(pci_ids(""), None); +} + +/// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary +/// display, and none is listed twice. (A headless CI runner may list none.) +#[cfg(windows)] +#[test] +fn windows_lists_each_gpu_with_a_monitor_once() { + let gpus = windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert_ne!(g.pci.0, 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!is_internal_panel(name), "{name}"); + } +} + +/// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port +/// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without +/// PCI ids. Each GPU is listed once, the one with the panel as primary. +#[test] +fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("designcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card0", AMD_IGPU)?; + card("card1", NVIDIA_3090)?; + std::fs::create_dir_all(dir.join("card2"))?; + connector("card0-eDP-1", "connected")?; + connector("card0-HDMI-A-1", "disconnected")?; + connector("card1-DP-1", "connected")?; + connector("card1-DP-2", "connected")?; + connector("card2-Virtual-1", "connected")?; + std::fs::write(dir.join("renderD128"), "")?; + let mut gpus = linux_display_gpus(&dir); + gpus.sort_by_key(|g| g.pci); + assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); + assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); + std::fs::remove_dir_all(&dir) +} + +#[test] +fn display_gpus_are_logged_as_pci_ids() { + assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); + assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); + let mut gpus = vec![]; + add_display_gpu(&mut gpus, NVIDIA_3090, false); + add_display_gpu(&mut gpus, NVIDIA_3090, true); + add_display_gpu(&mut gpus, AMD_IGPU, false); + assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); +} + +/// A dual-GPU Linux desktop as Vulkan lists it with Mesa's device-select layer: the GPU KDE's +/// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software +/// renderer, and OpenGL. +fn wayland_desktop() -> Vec { + vec![ + gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), + gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), + ] +} + +#[test] +fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { + let c = wayland_desktop(); + let order = adapter_order(&c, power_preference(None), &[], None, &[]); + let first = &c[order[0]].name; + if cfg!(any(windows, target_os = "macos")) { + assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + } else { + // The integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. + assert_eq!(first, "NVIDIA GeForce RTX 5070"); + } +} + +#[test] +fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { + let c = wayland_desktop(); + let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[])); + assert_eq!( + order(PowerPreference::None), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::LowPower), + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::HighPerformance), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +/// A restart leaves out the adapter the window failed on, and once every adapter failed there +/// is nothing to try. A dual-GPU Mac reports both GPUs under Metal with no PCI ids: their keys +/// still differ, so when the integrated GPU fails, the restart renders on the discrete one. +#[test] +fn gpus_without_pci_ids_are_told_apart_by_name() { + let mac = |name: &str, device_type| Candidate { + key: key(Backend::Metal, 0, 0, name), + name: name.into(), + backend: Backend::Metal, + device_type, + pci: (0, 0), + presents: true, + }; + let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; + assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); + let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); + let skip = parse_skip(&c[first[0]].key); + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]); + // A comma in a name can't split the list. + assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); +} + +fn sample(frames: u64, busy: bool, leaves: u64, hidden: bool) -> Sample { + Sample { frames, busy, leaves, hidden } +} + +/// Runs `ticks` looks with the same sample; the verdicts other than `Wait`. +fn run(w: &mut Watch, s: Sample, ticks: u32) -> Vec<(u32, Verdict)> { + (1..=ticks).filter_map(|t| Some((t, w.step(s, WATCH_TICK))).filter(|(_, v)| *v != Verdict::Wait)).collect() +} + +const TICKS_PER_S: u32 = 4; + +/// The UI thread stuck in the graphics stack (not in the app's code, no progress) with the +/// window not known to be hidden: late after 20 s, hung after 60 s. +#[test] +fn a_ui_thread_stuck_in_the_graphics_stack_hangs_its_adapter() { + let mut w = Watch::default(); + let v = run(&mut w, sample(1, false, 1, false), 61 * TICKS_PER_S); + assert_eq!(v.first(), Some(&(20 * TICKS_PER_S, Verdict::Late))); + // `leaves` moved from 0 to 1 on the first look, so the stuck time starts one look later. + assert_eq!(v.get(1), Some(&(60 * TICKS_PER_S + 1, Verdict::Hung))); + // The second frame began: the first was presented. + assert_eq!(Watch::default().step(sample(2, false, 9, false), WATCH_TICK), Verdict::Shown); +} + +/// A busy UI thread (a large document composing on a slow machine) is only ever late. +#[test] +fn a_busy_ui_thread_never_hangs_its_adapter() { + let mut w = Watch::default(); + let v = run(&mut w, sample(0, true, 0, false), 600 * TICKS_PER_S); + assert_eq!(v, [(20 * TICKS_PER_S, Verdict::Late)]); + // Nor does one that keeps coming back from the graphics stack. + let mut w = Watch::default(); + for t in 0..(600 * TICKS_PER_S) { + assert_ne!(w.step(sample(1, false, u64::from(t), false), WATCH_TICK), Verdict::Hung); + } +} + +/// A window known to be hidden (locked screen, another workspace, minimized) may have its frames +/// held back by the compositor: that time counts for nothing and starts the stuck time over. +#[test] +fn time_while_the_window_is_hidden_does_not_count() { + let mut w = Watch::default(); + assert!(run(&mut w, sample(1, false, 0, true), 600 * TICKS_PER_S).is_empty()); + assert!(run(&mut w, sample(1, false, 0, false), 50 * TICKS_PER_S).iter().all(|(_, v)| *v == Verdict::Late)); + assert!(run(&mut w, sample(1, false, 0, true), 10 * TICKS_PER_S).is_empty()); + assert!(run(&mut w, sample(1, false, 0, false), 59 * TICKS_PER_S).is_empty()); + assert_eq!(run(&mut w, sample(1, false, 0, false), TICKS_PER_S), [(TICKS_PER_S, Verdict::Hung)]); +} + +#[test] +fn a_hung_adapter_is_left_out_once() { + let skip = vec!["Dx12:10de:28a0".to_string()]; + assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); + assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); +} + +/// Windows, a hardware DX12 adapter failing: the software renderer (WARP) is passed over while +/// another backend is left, the restarts go through OpenGL and Vulkan, and only then back to +/// every backend with software allowed. The chain ends. +#[test] +fn software_renderers_come_after_every_backends_hardware() { + use backend::Backend as B; + let c = ryzen_desktop(); + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(without_software(order.clone(), &c, false), (order.clone(), false)); + let (hardware, dropped) = without_software(order, &c, true); + assert!(dropped && !hardware.contains(&4), "{hardware:?}"); + + let dx12 = "Dx12:10de:2204".to_string(); + // The adapter that failed first. + let (skip, _) = next_start(&[], Some(&dx12), Some((B::Dx12, Some(B::Gl))), true).unwrap(); + assert_eq!(skip, [dx12.as_str()]); + // Only WARP left on DX12 (passed over, nothing picked): OpenGL next. + let (skip, what) = next_start(&skip, None, Some((B::Dx12, Some(B::Gl))), true).unwrap(); + assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT]); + assert!(what.contains("OpenGL"), "{what}"); + // OpenGL has no adapter, Vulkan next; then Vulkan has none and no backend is left. + let (skip, _) = next_start(&skip, None, Some((B::Gl, Some(B::Vulkan))), false).unwrap(); + assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT, "Gl"]); + let (skip, what) = next_start(&skip, None, Some((B::Vulkan, None)), false).unwrap(); + assert_eq!(skip, [dx12.as_str(), SOFTWARE], "{what}"); + assert!(backend::skipped_backends(&skip).is_empty()); + // With software allowed and nothing left: the end. + assert_eq!(next_start(&skip, None, Some((B::Dx12, None)), false), None); + // Nothing was passed over (a Mac whose GPUs all failed): no software round. + assert_eq!(next_start(&[], None, Some((B::Metal, None)), false), None); + // Without the backend fallback (`WGPU_BACKEND`), only adapters. + assert_eq!(next_start(&[], None, None, false), None); +} + +/// The skip list travels as an argument, which processes the app starts don't inherit. +#[test] +fn the_skip_list_is_an_argument() { + assert_eq!(skip_arg("--gpu-skip=Dx12:10de:2204,Dx12"), Some(vec!["Dx12:10de:2204".to_string(), "Dx12".to_string()])); + assert_eq!(skip_arg("--gpu-skip="), Some(vec![])); + assert_eq!(skip_arg("--gpu-skip"), None); + assert_eq!(skip_arg("layout.designcraft"), None); +} + +#[test] +fn a_restart_tries_the_next_adapter() { + let c = wayland_desktop(); + let mut skip = vec![]; + let mut tried = vec![]; + while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() { + tried.push(c[i].name.clone()); + skip.push(c[i].key.clone()); + } + assert_eq!( + tried, + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +#[test] +fn adapters_that_cannot_present_to_the_window_are_never_tried() { + let mut c = wayland_desktop(); + c[0].presents = false; + c[3].presents = false; + for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { + let order = adapter_order(&c, power, &[], None, &[]); + assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); + assert_eq!(order.len(), 2); + } + for p in &mut c { + p.presents = false; + } + assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty()); +} + +#[test] +fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { + let c = wayland_desktop(); + let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]); + assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + assert_eq!(order.len(), 4); + // Any case; every match comes before the rest, natively first. + let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]); + assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); + // An empty name or one nothing matches changes nothing. + for named in ["", "no such gpu"] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]), adapter_order(&c, PowerPreference::LowPower, &[], None, &[])); + } + // A named adapter that failed is left out like any other. + let skip = vec![c[1].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0); +} + +/// A Windows machine lists every GPU twice when Vulkan and DX12 are both in the instance: +/// power saving still takes the integrated GPU first, each GPU through DX12 first. +#[test] +fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { + let c = vec![ + gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + ]; + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(order, [3, 1, 2, 0, 4]); + assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]); +} + +/// One Intel GPU whose Vulkan driver made the window flicker black: DX12 comes first whatever +/// the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` or a restart leaving DX12 +/// out still reach it. +#[test] +fn a_windows_gpu_renders_through_dx12_before_vulkan() { + let c = vec![ + gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), + ]; + for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}"); + } + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); +} + +#[test] +fn power_preference_follows_the_environment() { + assert_eq!(power_preference(None), AUTOMATIC); + for p in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(power_preference(Some(p)), p); + } + assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); +} + +/// The Intel Mac of #334 lost its Metal device compiling the compute shader of wgpu's indirect +/// call check: off unless `WGPU_VALIDATION_INDIRECT_CALL` turns it on. +#[test] +fn the_indirect_call_check_is_off() { + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + assert!(!instance_flags().contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + // The other flags of the build stay as wgpu sets them. + let others = wgpu::InstanceFlags::from_build_config() - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL; + if std::env::vars_os().all(|(k, _)| !k.to_string_lossy().starts_with("WGPU_")) { + assert_eq!(instance_flags(), others); + } +} + +#[test] +fn skipped_adapters_parse_from_a_comma_separated_list() { + assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); + assert!(parse_skip("").is_empty()); + assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); + assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); + // A whole backend left out is its bare name; it never matches an adapter's key. + let skip = parse_skip("Dx12,Dx12:10de:2204"); + assert_eq!(backend::skipped_backends(&skip), [backend::Backend::Dx12]); + let c = vec![pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090)]; + assert!(adapter_order(&c, PowerPreference::None, &[], None, &skip).is_empty()); + assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, &skip[..1]), [0]); +} + +#[test] +fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { + for file in [ + "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", + r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", + "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", + "naga-30.0.0/src/back/spv/writer.rs", + ] { + assert!(in_graphics_stack(file), "{file}"); + } + for file in + ["apps/designcraft/src/main.rs", "/home/wgpu/designcraft/crates/ui-egui/src/lib.rs", "egui-0.36.2/src/context.rs", "winit-0.30.12/src/x.rs"] + { + assert!(!in_graphics_stack(file), "{file}"); + } +} + +#[test] +fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { + let amd = Some("Vulkan:1002:164e"); + assert_eq!(restart_without(true, 0, amd, &[]), amd); + assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); + // After start-up the adapter has shown frames; a lost window is something else. + assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); + // A bug outside the graphics, or no adapter picked: nothing another adapter would change. + assert_eq!(restart_without(false, 0, amd, &[]), None); + assert_eq!(restart_without(true, 0, None, &[]), None); + // Never the same adapter twice, so restarts end. + assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); +} + +/// Without a backend fallback (`WGPU_BACKEND` set), a start whose graphics failed with no +/// adapter to leave out ends with the error and the hint; a bug ends with its message. +#[test] +fn a_normal_exit_or_another_failure_does_not_restart() { + let startup = Startup::new(None, Vec::new()); + assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); + // No adapter was picked (none can show the window): the error is returned, nothing restarts. + let none = eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); + assert_eq!( + finish(Ok(Err(none)), &startup), + Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) + ); + let _ = startup.adapter.set("Vulkan:1002:164e".into()); + GRAPHICS_PANIC.store(false, Ordering::Relaxed); + assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); + // Nothing to record without a fallback. + startup.presented(); + startup.started_with(wgpu::Backend::Gl); + assert_eq!(startup.status_line(), None); +} diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms.rs similarity index 100% rename from crates/color/src/cms/mod.rs rename to crates/color/src/cms.rs diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen.rs similarity index 99% rename from crates/compose/src/hyphen/mod.rs rename to crates/compose/src/hyphen.rs index 57b75d63b9..3f07f4b40b 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen.rs @@ -26,8 +26,8 @@ use std::sync::OnceLock; pub use dict::Dictionary; pub use patterns::Patterns; -static EN_US_DIC: &[u8] = include_bytes!("../../../../assets/hyphenation/en-us.dic"); -static EN_US_PAT: &str = include_str!("../../../../assets/hyphenation/en-us.pat"); +static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic"); +static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat"); impl Dictionary { /// The bundled US English dictionary (decoded on first use). diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 9a41a546ed..c620622a3f 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -1096,483 +1096,4 @@ mod height_tests { } #[cfg(test)] -mod border_tests { - use super::*; - use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument}; - - fn compose_table(table: Table) -> ComposedStory { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap(); - doc.story_mut(sid).unwrap().insert_table(0, table); - crate::compose_story(&doc, sid, &ComposeOptions::default()) - } - - #[test] - fn explicit_cell_edges_override_table_border_without_changing_native_defaults() { - let mut table = Table::new(77, 1, 1, 0, 0, 100.0); - table.options.border.weight = 2.0; - table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none()); - let native = compose_table(table.clone()); - let strokes = &native.frames[0].tables[0].strokes; - assert_eq!(strokes.len(), 4, "default cells still use all four table borders"); - assert!(strokes.iter().all(|s| s.stroke.weight == 2.0)); - - let cell = table.cell_mut(0, 0).unwrap(); - cell.border_overrides = [true, true, true, false]; - cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() }; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders"); - let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap(); - assert_eq!(bottom.stroke.weight, 3.0); - let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap(); - assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border"); - } - - #[test] - fn merged_cell_keeps_explicit_border_suppression() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); - let cell = table.cell_mut(0, 0).unwrap(); - cell.border_overrides = [true; 4]; - cell.strokes = std::array::from_fn(|_| CellStroke::none()); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.cells.len(), 1); - assert!(fragment.strokes.is_empty()); - } - - fn rule_table(body: usize, cols: usize, header: usize, footer: usize) -> Table { - let mut table = Table::new(77, body, cols, header, footer, 100.0); - table.options.border = CellStroke::none(); - for row in &mut table.rows { - row.height = 20.0; - row.mode = RowHeightMode::Exactly; - } - for cell in &mut table.cells { - cell.insets = [0.0; 4]; - cell.strokes = std::array::from_fn(|_| CellStroke::none()); - } - table - } - - fn set_rule(table: &mut Table, row: usize, col: usize, side: usize, weight: f64, priority: i32) { - let cell = table.cell_mut(row, col).unwrap(); - cell.strokes[side] = CellStroke { weight, ..Default::default() }; - cell.stroke_defined[side] = true; - cell.stroke_priorities[side] = priority; - } - - fn assert_no_overlapping_rules(fragment: &TableFrag) { - for (i, a) in fragment.strokes.iter().enumerate() { - for b in &fragment.strokes[i + 1..] { - let horizontal_overlap = a.a.y == a.b.y && a.a.y == b.a.y && b.a.y == b.b.y && a.a.x.max(b.a.x) < a.b.x.min(b.b.x); - let vertical_overlap = a.a.x == a.b.x && a.a.x == b.a.x && b.a.x == b.b.x && a.a.y.max(b.a.y) < a.b.y.min(b.b.y); - assert!(!horizontal_overlap && !vertical_overlap, "duplicate shared edge: {a:?}, {b:?}"); - } - } - } - - #[test] - fn header_bottom_only_style_survives_zero_body_edges() { - // Synthetic equivalent of a header with a one-point bottom rule and zero on all - // remaining header/body edges. Both sides supply style formatting at priority zero. - let mut table = rule_table(2, 3, 1, 0); - for cell in &mut table.cells { - cell.stroke_defined = [true; 4]; - cell.border_overrides = [true; 4]; - } - for col in 0..3 { - set_rule(&mut table, 0, col, 2, 1.0, 0); - } - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - let y = fragment.cell(0, 0).unwrap().rect.y1; - assert_eq!(fragment.strokes.len(), 3); - assert!(fragment.strokes.iter().all(|s| s.a.y == y && s.b.y == y && s.stroke.weight == 1.0)); - assert_eq!(fragment.strokes.iter().map(|s| s.b.x - s.a.x).sum::(), 100.0); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn shared_rules_choose_numeric_priority_once_and_preserve_drawing_order() { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 3.0, 7); - set_rule(&mut table, 1, 0, 0, 1.0, 4); - set_rule(&mut table, 0, 0, 3, 4.0, 3); - set_rule(&mut table, 0, 1, 1, 2.0, 9); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let horizontal = &fragment.strokes[0]; - assert_eq!(horizontal.a.y, horizontal.b.y); - assert_eq!(horizontal.stroke.weight, 3.0); - let vertical = &fragment.strokes[1]; - assert_eq!(vertical.a.x, vertical.b.x); - assert_eq!(vertical.stroke.weight, 2.0, "higher priority draws last at a crossing"); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn signed_negative_priorities_resolve_and_draw_in_numeric_order() { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 1.0, -2); - set_rule(&mut table, 1, 0, 0, 3.0, -1); - set_rule(&mut table, 0, 0, 3, 2.0, -3); - set_rule(&mut table, 0, 1, 1, 4.0, -2); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - assert_eq!(fragment.strokes[0].stroke.weight, 4.0, "priority -2 draws before -1"); - assert_eq!(fragment.strokes[0].a.x, fragment.strokes[0].b.x); - assert_eq!(fragment.strokes[1].stroke.weight, 3.0, "priority -1 wins over -2"); - assert_eq!(fragment.strokes[1].a.y, fragment.strokes[1].b.y); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn default_zero_priority_beats_a_negative_priority_rule() { - let mut table = rule_table(2, 1, 0, 0); - set_rule(&mut table, 0, 0, 2, 3.0, -1); - assert_eq!(table.cell(1, 0).unwrap().stroke_priorities, [0; 4]); - let composed = compose_table(table.clone()); - assert!(composed.frames[0].tables[0].strokes.is_empty(), "priority zero None suppresses priority -1"); - table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::default(); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 1); - assert_eq!(fragment.strokes[0].stroke.weight, 1.0, "the priority-zero native rule wins over priority -1"); - } - - #[test] - fn higher_priority_zero_or_none_suppresses_a_visible_shared_rule() { - for invisible in [CellStroke::none(), CellStroke { color: designcraft_color::swatch::NONE.into(), ..Default::default() }] { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 3.0, 7); - set_rule(&mut table, 1, 0, 0, 0.0, 8); - table.cell_mut(1, 0).unwrap().strokes[0] = invisible.clone(); - set_rule(&mut table, 0, 0, 3, 3.0, 7); - set_rule(&mut table, 0, 1, 1, 0.0, 8); - table.cell_mut(0, 1).unwrap().strokes[1] = invisible; - let composed = compose_table(table); - assert!(composed.frames[0].tables[0].strokes.is_empty()); - } - } - - #[test] - fn supplied_zero_shared_edge_beats_missing_native_default() { - let mut table = Table::new(77, 2, 1, 0, 0, 100.0); - table.options.border = CellStroke::none(); - let upper = table.cell_mut(0, 0).unwrap(); - upper.strokes[2] = CellStroke::none(); - upper.stroke_defined[2] = true; - let composed = compose_table(table.clone()); - assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit zero beats a missing edge's one-point default"); - table.cell_mut(0, 0).unwrap().stroke_defined[2] = false; - let composed = compose_table(table); - assert_eq!(composed.frames[0].tables[0].strokes.len(), 1, "a missing zero cannot suppress the opposite visible default"); - } - - #[test] - fn specified_zero_priority_outer_edges_keep_per_side_table_borders() { - for direction in [designcraft_doc::TextDirection::LeftToRight, designcraft_doc::TextDirection::RightToLeft] { - let mut table = rule_table(1, 1, 0, 0); - table.options.direction = direction; - table.options.borders = std::array::from_fn(|i| Some(CellStroke { weight: (i + 1) as f64, ..Default::default() })); - table.cell_mut(0, 0).unwrap().stroke_defined = [true; 4]; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 4); - for rule in &fragment.strokes { - let expected = if rule.a.y == rule.b.y { - if rule.a.y == fragment.rect.y0 { 1.0 } else { 3.0 } - } else if rule.a.x == fragment.rect.x0 { - 2.0 - } else { - 4.0 - }; - assert_eq!(rule.stroke.weight, expected, "priority-zero cell values do not override perimeter formatting"); - } - assert_no_overlapping_rules(fragment); - } - } - - fn uniform_pattern(weight: f64) -> designcraft_doc::AltStrokes { - let stroke = CellStroke { weight, ..Default::default() }; - designcraft_doc::AltStrokes { first: 1, next: 1, first_stroke: stroke.clone(), next_stroke: stroke, skip_first: 0, skip_last: 0 } - } - - #[test] - fn uniform_zero_table_patterns_suppress_missing_grid_edges_but_not_perimeter() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.row_strokes = Some(uniform_pattern(0.0)); - table.options.column_strokes = Some(uniform_pattern(0.0)); - table.options.border.weight = 2.0; - let composed = compose_table(table.clone()); - let fragment = &composed.frames[0].tables[0]; - assert!(!fragment.strokes.is_empty(), "table perimeter remains visible"); - assert!(fragment.strokes.iter().all(|s| { - s.stroke.weight == 2.0 - && ((s.a.x == s.b.x && (s.a.x == fragment.rect.x0 || s.a.x == fragment.rect.x1)) - || (s.a.y == s.b.y && (s.a.y == fragment.rect.y0 || s.a.y == fragment.rect.y1))) - })); - assert_no_overlapping_rules(fragment); - table.options.border = CellStroke::none(); - set_rule(&mut table, 0, 0, 2, 3.0, 0); - set_rule(&mut table, 0, 1, 1, 4.0, 0); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2, "supplied cell strokes override the table patterns"); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0)); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 4.0)); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn uniform_visible_table_patterns_supply_continuous_rules_below_explicit_none() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.border = CellStroke::none(); - table.options.row_strokes = Some(uniform_pattern(2.0)); - table.options.column_strokes = Some(uniform_pattern(3.0)); - let composed = compose_table(table.clone()); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2, "a table pattern stays continuous across cell boundaries"); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 2.0 && s.b.x - s.a.x == 100.0)); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0 && s.b.y - s.a.y == fragment.rect.height())); - for cell in &mut table.cells { - cell.strokes = std::array::from_fn(|_| CellStroke::none()); - cell.stroke_defined = [true; 4]; - } - let composed = compose_table(table); - assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit cell None is stronger than table patterns"); - } - - #[test] - fn uniform_table_patterns_leave_header_and_footer_edges_unchanged() { - let mut table = Table::new(77, 2, 2, 1, 1, 100.0); - table.options.border = CellStroke::none(); - table.options.row_strokes = Some(uniform_pattern(0.0)); - table.options.column_strokes = Some(uniform_pattern(0.0)); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - let header = fragment.cell(0, 0).unwrap().rect; - let body = fragment.cell(1, 0).unwrap().rect; - let footer = fragment.cell(3, 0).unwrap().rect; - assert!(fragment.strokes.iter().any(|s| s.a == Point::new(header.x1, header.y0) && s.b == Point::new(header.x1, header.y1))); - assert!(fragment.strokes.iter().any(|s| s.a == Point::new(footer.x1, footer.y0) && s.b == Point::new(footer.x1, footer.y1))); - assert!(!fragment.strokes.iter().any(|s| s.a.y == body.y1 && s.b.y == body.y1)); - assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(body.x1, body.y0) && s.b == Point::new(body.x1, body.y1))); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn unverified_nonuniform_or_skipped_pattern_phase_keeps_native_edges() { - for skipped in [false, true] { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.border = CellStroke::none(); - let mut pattern = uniform_pattern(2.0); - if skipped { - pattern.skip_first = 1; - } else { - pattern.next_stroke.weight = 3.0; - } - table.options.row_strokes = Some(pattern.clone()); - table.options.column_strokes = Some(pattern); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 4); - assert!(fragment.strokes.iter().all(|s| s.stroke.weight == 1.0)); - } - } - - #[test] - fn metadata_free_native_and_legacy_edges_keep_top_and_left_suppression() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.border = CellStroke::none(); - let composed = compose_table(table.clone()); - assert_eq!(composed.frames[0].tables[0].strokes.len(), 4, "unmodified native grid control"); - // The metadata arrays also default to zero/false when reading legacy JSON. Older - // native table.setCell code changed these strokes without setting any metadata. - table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::none(); - table.cell_mut(0, 1).unwrap().strokes[1].color = designcraft_color::swatch::NONE.into(); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let hidden_top = fragment.cell(1, 0).unwrap().rect; - let hidden_left = fragment.cell(0, 1).unwrap().rect; - assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_top.x0, hidden_top.y0) && s.b == Point::new(hidden_top.x1, hidden_top.y0))); - assert!( - !fragment.strokes.iter().any(|s| s.a == Point::new(hidden_left.x0, hidden_left.y0) && s.b == Point::new(hidden_left.x0, hidden_left.y1)) - ); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn equal_priority_visible_rules_keep_top_and_left_ownership() { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 5.0, 0); - set_rule(&mut table, 1, 0, 0, 2.0, 0); - set_rule(&mut table, 0, 0, 3, 6.0, 0); - set_rule(&mut table, 0, 1, 1, 3.0, 0); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - assert_eq!(fragment.strokes[0].stroke.weight, 2.0); - assert_eq!(fragment.strokes[1].stroke.weight, 3.0); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn row_spanning_edge_resolves_each_neighbor_without_internal_rules() { - let mut table = rule_table(2, 2, 0, 0); - table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); - set_rule(&mut table, 0, 0, 3, 1.0, 5); - set_rule(&mut table, 0, 1, 1, 2.0, 6); - set_rule(&mut table, 1, 1, 1, 3.0, 4); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let upper = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); - let lower = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); - assert_eq!(upper.a, Point::new(50.0, fragment.rect.y0)); - assert_eq!(upper.b, lower.a); - assert_eq!(lower.b, Point::new(50.0, fragment.rect.y1)); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn column_spanning_edge_resolves_each_neighbor_without_internal_rules() { - let mut table = rule_table(2, 2, 0, 0); - table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); - set_rule(&mut table, 0, 0, 2, 1.0, 5); - set_rule(&mut table, 1, 0, 0, 2.0, 6); - set_rule(&mut table, 1, 1, 0, 3.0, 4); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let left = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); - let right = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); - let y = fragment.cell(0, 0).unwrap().rect.y1; - assert_eq!(left.a, Point::new(fragment.rect.x0, y)); - assert_eq!(left.b, right.a); - assert_eq!(right.b, Point::new(fragment.rect.x1, y)); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn merged_winning_dashed_edge_stays_continuous_across_losing_neighbors() { - let mut table = rule_table(2, 2, 0, 0); - table.columns.iter_mut().for_each(|column| column.width = 45.0); - table.merge(CellRange::new(1, 0, 1, 1)).unwrap(); - set_rule(&mut table, 1, 0, 0, 1.0, 1); - table.cell_mut(1, 0).unwrap().strokes[0].kind = designcraft_doc::StrokeType::Dashed { pattern: vec![6.0, 4.0] }; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 1, "one winning source edge must retain one dash phase"); - let rule = &fragment.strokes[0]; - let merged = fragment.cell(1, 0).unwrap().rect; - assert_eq!(rule.a, Point::new(merged.x0, merged.y0)); - assert_eq!(rule.b, Point::new(merged.x1, merged.y0)); - assert_eq!(rule.b.x - rule.a.x, 90.0); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn merged_perimeter_keeps_four_continuous_strokes() { - let mut table = rule_table(2, 2, 0, 0); - table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); - table.options.border.weight = 2.0; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 4); - assert_eq!(fragment.strokes.iter().filter(|s| s.a.y == s.b.y).count(), 2); - assert_eq!(fragment.strokes.iter().filter(|s| s.a.x == s.b.x).count(), 2); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn rtl_shared_rules_use_physical_edges_and_outer_borders() { - let mut table = rule_table(1, 2, 0, 0); - table.options.direction = designcraft_doc::TextDirection::RightToLeft; - table.columns[0].width = 30.0; - table.columns[1].width = 70.0; - set_rule(&mut table, 0, 0, 1, 2.0, 9); - set_rule(&mut table, 0, 1, 3, 4.0, 2); - set_rule(&mut table, 0, 0, 3, 5.0, 3); - table.cell_mut(0, 0).unwrap().border_overrides[3] = true; - set_rule(&mut table, 0, 1, 1, 6.0, 4); - table.cell_mut(0, 1).unwrap().border_overrides[1] = true; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 3); - for (x, weight) in [(0.0, 6.0), (70.0, 2.0), (100.0, 5.0)] { - let rule = fragment.strokes.iter().find(|s| s.a.x == x && s.b.x == x).unwrap(); - assert_eq!(rule.stroke.weight, weight); - } - assert_no_overlapping_rules(fragment); - } - - fn compose_two_fragments(table: Table, height: f64) -> ComposedStory { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let (first, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, height), layer, "", ParaFormat::default()).unwrap(); - let (second, _) = - doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 120.0, 200.0 + height), layer, "", ParaFormat::default()).unwrap(); - doc.thread(first, second).unwrap(); - doc.story_mut(sid).unwrap().insert_table(0, table); - let composed = crate::compose_story(&doc, sid, &ComposeOptions::default()); - assert!(!composed.is_overset(), "overset at {:?}", composed.overset_at); - assert_eq!(composed.frames[0].tables.len(), 1); - assert_eq!(composed.frames[1].tables.len(), 1); - composed - } - - #[test] - fn repeated_header_and_footer_rules_resolve_displayed_fragment_neighbors() { - let mut table = rule_table(4, 2, 1, 1); - table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); - table.merge(CellRange::new(5, 0, 5, 1)).unwrap(); - set_rule(&mut table, 0, 0, 2, 1.0, 2); - set_rule(&mut table, 5, 0, 0, 2.0, 3); - // Keep 19 points for the anchor's trailing empty paragraph, still below the - // 100 points needed to fit a third body row plus the header and footer. - let composed = compose_two_fragments(table, 99.0); - for (i, frame) in composed.frames.iter().enumerate() { - let fragment = &frame.tables[0]; - let header = fragment.cell(0, 0).unwrap(); - let footer = fragment.cell(5, 0).unwrap(); - assert_eq!(header.repeated, i == 1); - assert_eq!(footer.repeated, i == 0); - assert_eq!(fragment.strokes.len(), 2); - let header_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == header.rect.y1 && s.b.y == header.rect.y1).collect(); - let footer_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == footer.rect.y0 && s.b.y == footer.rect.y0).collect(); - assert_eq!(header_rules.len(), 1); - assert!(header_rules.iter().all(|s| s.stroke.weight == 1.0)); - assert_eq!(footer_rules.len(), 1); - assert!(footer_rules.iter().all(|s| s.stroke.weight == 2.0)); - assert_no_overlapping_rules(fragment); - } - assert!(composed.frames[0].tables[0].cell(1, 0).is_some()); - assert!(composed.frames[1].tables[0].cell(3, 0).is_some()); - } - - #[test] - fn fragment_boundary_does_not_resolve_against_an_undisplayed_neighbor() { - let mut table = rule_table(4, 1, 0, 0); - set_rule(&mut table, 1, 0, 2, 3.0, 5); - table.cell_mut(1, 0).unwrap().border_overrides[2] = true; - set_rule(&mut table, 2, 0, 0, 4.0, 6); - table.cell_mut(2, 0).unwrap().border_overrides[0] = true; - // Two rows fit in each frame; leave room for the trailing empty paragraph. - let composed = compose_two_fragments(table, 59.0); - let first = &composed.frames[0].tables[0]; - let second = &composed.frames[1].tables[0]; - assert_eq!(first.strokes.len(), 1); - assert_eq!(first.strokes[0].stroke.weight, 3.0); - assert_eq!(first.strokes[0].a.y, first.rect.y1); - assert_eq!(second.strokes.len(), 1); - assert_eq!(second.strokes[0].stroke.weight, 4.0); - assert_eq!(second.strokes[0].a.y, second.rect.y0); - } -} +mod border_tests; diff --git a/crates/compose/src/table/border_tests.rs b/crates/compose/src/table/border_tests.rs new file mode 100644 index 0000000000..5a28d72746 --- /dev/null +++ b/crates/compose/src/table/border_tests.rs @@ -0,0 +1,475 @@ +use super::*; +use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument}; + +fn compose_table(table: Table) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + crate::compose_story(&doc, sid, &ComposeOptions::default()) +} + +#[test] +fn explicit_cell_edges_override_table_border_without_changing_native_defaults() { + let mut table = Table::new(77, 1, 1, 0, 0, 100.0); + table.options.border.weight = 2.0; + table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none()); + let native = compose_table(table.clone()); + let strokes = &native.frames[0].tables[0].strokes; + assert_eq!(strokes.len(), 4, "default cells still use all four table borders"); + assert!(strokes.iter().all(|s| s.stroke.weight == 2.0)); + + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true, true, true, false]; + cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders"); + let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap(); + assert_eq!(bottom.stroke.weight, 3.0); + let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap(); + assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border"); +} + +#[test] +fn merged_cell_keeps_explicit_border_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.cells.len(), 1); + assert!(fragment.strokes.is_empty()); +} + +fn rule_table(body: usize, cols: usize, header: usize, footer: usize) -> Table { + let mut table = Table::new(77, body, cols, header, footer, 100.0); + table.options.border = CellStroke::none(); + for row in &mut table.rows { + row.height = 20.0; + row.mode = RowHeightMode::Exactly; + } + for cell in &mut table.cells { + cell.insets = [0.0; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + } + table +} + +fn set_rule(table: &mut Table, row: usize, col: usize, side: usize, weight: f64, priority: i32) { + let cell = table.cell_mut(row, col).unwrap(); + cell.strokes[side] = CellStroke { weight, ..Default::default() }; + cell.stroke_defined[side] = true; + cell.stroke_priorities[side] = priority; +} + +fn assert_no_overlapping_rules(fragment: &TableFrag) { + for (i, a) in fragment.strokes.iter().enumerate() { + for b in &fragment.strokes[i + 1..] { + let horizontal_overlap = a.a.y == a.b.y && a.a.y == b.a.y && b.a.y == b.b.y && a.a.x.max(b.a.x) < a.b.x.min(b.b.x); + let vertical_overlap = a.a.x == a.b.x && a.a.x == b.a.x && b.a.x == b.b.x && a.a.y.max(b.a.y) < a.b.y.min(b.b.y); + assert!(!horizontal_overlap && !vertical_overlap, "duplicate shared edge: {a:?}, {b:?}"); + } + } +} + +#[test] +fn header_bottom_only_style_survives_zero_body_edges() { + // Synthetic equivalent of a header with a one-point bottom rule and zero on all + // remaining header/body edges. Both sides supply style formatting at priority zero. + let mut table = rule_table(2, 3, 1, 0); + for cell in &mut table.cells { + cell.stroke_defined = [true; 4]; + cell.border_overrides = [true; 4]; + } + for col in 0..3 { + set_rule(&mut table, 0, col, 2, 1.0, 0); + } + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(fragment.strokes.len(), 3); + assert!(fragment.strokes.iter().all(|s| s.a.y == y && s.b.y == y && s.stroke.weight == 1.0)); + assert_eq!(fragment.strokes.iter().map(|s| s.b.x - s.a.x).sum::(), 100.0); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn shared_rules_choose_numeric_priority_once_and_preserve_drawing_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 1.0, 4); + set_rule(&mut table, 0, 0, 3, 4.0, 3); + set_rule(&mut table, 0, 1, 1, 2.0, 9); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let horizontal = &fragment.strokes[0]; + assert_eq!(horizontal.a.y, horizontal.b.y); + assert_eq!(horizontal.stroke.weight, 3.0); + let vertical = &fragment.strokes[1]; + assert_eq!(vertical.a.x, vertical.b.x); + assert_eq!(vertical.stroke.weight, 2.0, "higher priority draws last at a crossing"); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn signed_negative_priorities_resolve_and_draw_in_numeric_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 1.0, -2); + set_rule(&mut table, 1, 0, 0, 3.0, -1); + set_rule(&mut table, 0, 0, 3, 2.0, -3); + set_rule(&mut table, 0, 1, 1, 4.0, -2); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 4.0, "priority -2 draws before -1"); + assert_eq!(fragment.strokes[0].a.x, fragment.strokes[0].b.x); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0, "priority -1 wins over -2"); + assert_eq!(fragment.strokes[1].a.y, fragment.strokes[1].b.y); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn default_zero_priority_beats_a_negative_priority_rule() { + let mut table = rule_table(2, 1, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, -1); + assert_eq!(table.cell(1, 0).unwrap().stroke_priorities, [0; 4]); + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "priority zero None suppresses priority -1"); + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::default(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1); + assert_eq!(fragment.strokes[0].stroke.weight, 1.0, "the priority-zero native rule wins over priority -1"); +} + +#[test] +fn higher_priority_zero_or_none_suppresses_a_visible_shared_rule() { + for invisible in [CellStroke::none(), CellStroke { color: designcraft_color::swatch::NONE.into(), ..Default::default() }] { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 0.0, 8); + table.cell_mut(1, 0).unwrap().strokes[0] = invisible.clone(); + set_rule(&mut table, 0, 0, 3, 3.0, 7); + set_rule(&mut table, 0, 1, 1, 0.0, 8); + table.cell_mut(0, 1).unwrap().strokes[1] = invisible; + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty()); + } +} + +#[test] +fn supplied_zero_shared_edge_beats_missing_native_default() { + let mut table = Table::new(77, 2, 1, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let upper = table.cell_mut(0, 0).unwrap(); + upper.strokes[2] = CellStroke::none(); + upper.stroke_defined[2] = true; + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit zero beats a missing edge's one-point default"); + table.cell_mut(0, 0).unwrap().stroke_defined[2] = false; + let composed = compose_table(table); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 1, "a missing zero cannot suppress the opposite visible default"); +} + +#[test] +fn specified_zero_priority_outer_edges_keep_per_side_table_borders() { + for direction in [designcraft_doc::TextDirection::LeftToRight, designcraft_doc::TextDirection::RightToLeft] { + let mut table = rule_table(1, 1, 0, 0); + table.options.direction = direction; + table.options.borders = std::array::from_fn(|i| Some(CellStroke { weight: (i + 1) as f64, ..Default::default() })); + table.cell_mut(0, 0).unwrap().stroke_defined = [true; 4]; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + for rule in &fragment.strokes { + let expected = if rule.a.y == rule.b.y { + if rule.a.y == fragment.rect.y0 { 1.0 } else { 3.0 } + } else if rule.a.x == fragment.rect.x0 { + 2.0 + } else { + 4.0 + }; + assert_eq!(rule.stroke.weight, expected, "priority-zero cell values do not override perimeter formatting"); + } + assert_no_overlapping_rules(fragment); + } +} + +fn uniform_pattern(weight: f64) -> designcraft_doc::AltStrokes { + let stroke = CellStroke { weight, ..Default::default() }; + designcraft_doc::AltStrokes { first: 1, next: 1, first_stroke: stroke.clone(), next_stroke: stroke, skip_first: 0, skip_last: 0 } +} + +#[test] +fn uniform_zero_table_patterns_suppress_missing_grid_edges_but_not_perimeter() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + table.options.border.weight = 2.0; + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert!(!fragment.strokes.is_empty(), "table perimeter remains visible"); + assert!(fragment.strokes.iter().all(|s| { + s.stroke.weight == 2.0 + && ((s.a.x == s.b.x && (s.a.x == fragment.rect.x0 || s.a.x == fragment.rect.x1)) + || (s.a.y == s.b.y && (s.a.y == fragment.rect.y0 || s.a.y == fragment.rect.y1))) + })); + assert_no_overlapping_rules(fragment); + table.options.border = CellStroke::none(); + set_rule(&mut table, 0, 0, 2, 3.0, 0); + set_rule(&mut table, 0, 1, 1, 4.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "supplied cell strokes override the table patterns"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 4.0)); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn uniform_visible_table_patterns_supply_continuous_rules_below_explicit_none() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(2.0)); + table.options.column_strokes = Some(uniform_pattern(3.0)); + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "a table pattern stays continuous across cell boundaries"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 2.0 && s.b.x - s.a.x == 100.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0 && s.b.y - s.a.y == fragment.rect.height())); + for cell in &mut table.cells { + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + cell.stroke_defined = [true; 4]; + } + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit cell None is stronger than table patterns"); +} + +#[test] +fn uniform_table_patterns_leave_header_and_footer_edges_unchanged() { + let mut table = Table::new(77, 2, 2, 1, 1, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let header = fragment.cell(0, 0).unwrap().rect; + let body = fragment.cell(1, 0).unwrap().rect; + let footer = fragment.cell(3, 0).unwrap().rect; + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(header.x1, header.y0) && s.b == Point::new(header.x1, header.y1))); + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(footer.x1, footer.y0) && s.b == Point::new(footer.x1, footer.y1))); + assert!(!fragment.strokes.iter().any(|s| s.a.y == body.y1 && s.b.y == body.y1)); + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(body.x1, body.y0) && s.b == Point::new(body.x1, body.y1))); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn unverified_nonuniform_or_skipped_pattern_phase_keeps_native_edges() { + for skipped in [false, true] { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let mut pattern = uniform_pattern(2.0); + if skipped { + pattern.skip_first = 1; + } else { + pattern.next_stroke.weight = 3.0; + } + table.options.row_strokes = Some(pattern.clone()); + table.options.column_strokes = Some(pattern); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert!(fragment.strokes.iter().all(|s| s.stroke.weight == 1.0)); + } +} + +#[test] +fn metadata_free_native_and_legacy_edges_keep_top_and_left_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let composed = compose_table(table.clone()); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 4, "unmodified native grid control"); + // The metadata arrays also default to zero/false when reading legacy JSON. Older + // native table.setCell code changed these strokes without setting any metadata. + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::none(); + table.cell_mut(0, 1).unwrap().strokes[1].color = designcraft_color::swatch::NONE.into(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let hidden_top = fragment.cell(1, 0).unwrap().rect; + let hidden_left = fragment.cell(0, 1).unwrap().rect; + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_top.x0, hidden_top.y0) && s.b == Point::new(hidden_top.x1, hidden_top.y0))); + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_left.x0, hidden_left.y0) && s.b == Point::new(hidden_left.x0, hidden_left.y1))); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn equal_priority_visible_rules_keep_top_and_left_ownership() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 5.0, 0); + set_rule(&mut table, 1, 0, 0, 2.0, 0); + set_rule(&mut table, 0, 0, 3, 6.0, 0); + set_rule(&mut table, 0, 1, 1, 3.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 2.0); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn row_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); + set_rule(&mut table, 0, 0, 3, 1.0, 5); + set_rule(&mut table, 0, 1, 1, 2.0, 6); + set_rule(&mut table, 1, 1, 1, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let upper = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let lower = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + assert_eq!(upper.a, Point::new(50.0, fragment.rect.y0)); + assert_eq!(upper.b, lower.a); + assert_eq!(lower.b, Point::new(50.0, fragment.rect.y1)); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn column_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 5); + set_rule(&mut table, 1, 0, 0, 2.0, 6); + set_rule(&mut table, 1, 1, 0, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let left = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let right = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(left.a, Point::new(fragment.rect.x0, y)); + assert_eq!(left.b, right.a); + assert_eq!(right.b, Point::new(fragment.rect.x1, y)); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn merged_winning_dashed_edge_stays_continuous_across_losing_neighbors() { + let mut table = rule_table(2, 2, 0, 0); + table.columns.iter_mut().for_each(|column| column.width = 45.0); + table.merge(CellRange::new(1, 0, 1, 1)).unwrap(); + set_rule(&mut table, 1, 0, 0, 1.0, 1); + table.cell_mut(1, 0).unwrap().strokes[0].kind = designcraft_doc::StrokeType::Dashed { pattern: vec![6.0, 4.0] }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1, "one winning source edge must retain one dash phase"); + let rule = &fragment.strokes[0]; + let merged = fragment.cell(1, 0).unwrap().rect; + assert_eq!(rule.a, Point::new(merged.x0, merged.y0)); + assert_eq!(rule.b, Point::new(merged.x1, merged.y0)); + assert_eq!(rule.b.x - rule.a.x, 90.0); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn merged_perimeter_keeps_four_continuous_strokes() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + table.options.border.weight = 2.0; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.y == s.b.y).count(), 2); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.x == s.b.x).count(), 2); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn rtl_shared_rules_use_physical_edges_and_outer_borders() { + let mut table = rule_table(1, 2, 0, 0); + table.options.direction = designcraft_doc::TextDirection::RightToLeft; + table.columns[0].width = 30.0; + table.columns[1].width = 70.0; + set_rule(&mut table, 0, 0, 1, 2.0, 9); + set_rule(&mut table, 0, 1, 3, 4.0, 2); + set_rule(&mut table, 0, 0, 3, 5.0, 3); + table.cell_mut(0, 0).unwrap().border_overrides[3] = true; + set_rule(&mut table, 0, 1, 1, 6.0, 4); + table.cell_mut(0, 1).unwrap().border_overrides[1] = true; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 3); + for (x, weight) in [(0.0, 6.0), (70.0, 2.0), (100.0, 5.0)] { + let rule = fragment.strokes.iter().find(|s| s.a.x == x && s.b.x == x).unwrap(); + assert_eq!(rule.stroke.weight, weight); + } + assert_no_overlapping_rules(fragment); +} + +fn compose_two_fragments(table: Table, height: f64) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (first, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, height), layer, "", ParaFormat::default()).unwrap(); + let (second, _) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 120.0, 200.0 + height), layer, "", ParaFormat::default()).unwrap(); + doc.thread(first, second).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + let composed = crate::compose_story(&doc, sid, &ComposeOptions::default()); + assert!(!composed.is_overset(), "overset at {:?}", composed.overset_at); + assert_eq!(composed.frames[0].tables.len(), 1); + assert_eq!(composed.frames[1].tables.len(), 1); + composed +} + +#[test] +fn repeated_header_and_footer_rules_resolve_displayed_fragment_neighbors() { + let mut table = rule_table(4, 2, 1, 1); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + table.merge(CellRange::new(5, 0, 5, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 2); + set_rule(&mut table, 5, 0, 0, 2.0, 3); + // Keep 19 points for the anchor's trailing empty paragraph, still below the + // 100 points needed to fit a third body row plus the header and footer. + let composed = compose_two_fragments(table, 99.0); + for (i, frame) in composed.frames.iter().enumerate() { + let fragment = &frame.tables[0]; + let header = fragment.cell(0, 0).unwrap(); + let footer = fragment.cell(5, 0).unwrap(); + assert_eq!(header.repeated, i == 1); + assert_eq!(footer.repeated, i == 0); + assert_eq!(fragment.strokes.len(), 2); + let header_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == header.rect.y1 && s.b.y == header.rect.y1).collect(); + let footer_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == footer.rect.y0 && s.b.y == footer.rect.y0).collect(); + assert_eq!(header_rules.len(), 1); + assert!(header_rules.iter().all(|s| s.stroke.weight == 1.0)); + assert_eq!(footer_rules.len(), 1); + assert!(footer_rules.iter().all(|s| s.stroke.weight == 2.0)); + assert_no_overlapping_rules(fragment); + } + assert!(composed.frames[0].tables[0].cell(1, 0).is_some()); + assert!(composed.frames[1].tables[0].cell(3, 0).is_some()); +} + +#[test] +fn fragment_boundary_does_not_resolve_against_an_undisplayed_neighbor() { + let mut table = rule_table(4, 1, 0, 0); + set_rule(&mut table, 1, 0, 2, 3.0, 5); + table.cell_mut(1, 0).unwrap().border_overrides[2] = true; + set_rule(&mut table, 2, 0, 0, 4.0, 6); + table.cell_mut(2, 0).unwrap().border_overrides[0] = true; + // Two rows fit in each frame; leave room for the trailing empty paragraph. + let composed = compose_two_fragments(table, 59.0); + let first = &composed.frames[0].tables[0]; + let second = &composed.frames[1].tables[0]; + assert_eq!(first.strokes.len(), 1); + assert_eq!(first.strokes[0].stroke.weight, 3.0); + assert_eq!(first.strokes[0].a.y, first.rect.y1); + assert_eq!(second.strokes.len(), 1); + assert_eq!(second.strokes[0].stroke.weight, 4.0); + assert_eq!(second.strokes[0].a.y, second.rect.y0); +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index e2237fe56c..bb6ae36864 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -113,6 +113,32 @@ macro_rules! attr_set { }; } +mod enums; +mod language; +mod rules_lists; +mod tabs; + +pub use enums::{ + Align, Capitalization, ChangeMark, Composer, Digits, GrepStyle, GridAlign, Kerning, Leading, NestedLineStyle, NestedStyle, NestedUntil, Position, + SpanColumns, StartParagraph, TextDirection, +}; +pub use language::{capitalizes, language_subtag, language_tag}; +use rules_lists::list_level_index; +pub use rules_lists::{ + BoxBottom, BoxTop, BoxWidth, LIST_LEVELS, ListAlign, ListCounter, ListType, NumberStyle, Rule, TEXT_COLOR, expand_list_levels, expand_list_text, +}; +use tabs::sanitized_tabs; +pub use tabs::{ + MAX_MEASURE, MAX_TAB_LEADER, MAX_TAB_POSITION, MAX_TAB_STOPS, MAX_TYPE_SIZE, MIN_TYPE_SIZE, SAME_TAB_POSITION, TabAlign, TabStop, + imported_measure, imported_type_size, +}; + +mod char_attrs; +mod para_attrs; + +pub use char_attrs::{CharAttrs, CharProps}; +pub use para_attrs::{ParaAttrs, ParaProps}; + fn attr_key(snake: &'static str) -> &'static str { snake } @@ -168,1652 +194,5 @@ fn unknown_attribute(key: &str, fields: &[&str]) -> String { } } -// ---------- value types ---------- - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "value")] -pub enum Leading { - /// Auto leading (percentage of the largest size on the line, Justification → Auto Leading). - #[default] - Auto, - Points(f64), -} - -/// Paragraph direction. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TextDirection { - #[default] - LeftToRight, - RightToLeft, -} - -/// Digits (World-Ready): the figures 0–9 are drawn as these. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Digits { - /// Contextual digits; composition selects the preceding strong script/language. - #[default] - Default, - /// European (0123). - Arabic, - /// Arabic-Indic (٠١٢٣). - Hindi, - /// Extended Arabic-Indic, as used for Persian and Urdu (۰۱۲۳). - Farsi, - /// Those of the text's language (Thai, Devanagari, Bengali, Arabic, Persian…); else as typed. - Native, -} - -impl Digits { - /// The digit `c` (0–9) is drawn as, for text in `language`. - pub fn map(self, c: char, language: &str) -> char { - let d = match c { - '0'..='9' => c as u32 - '0' as u32, - '\u{0660}'..='\u{0669}' => c as u32 - 0x0660, - '\u{06F0}'..='\u{06F9}' => c as u32 - 0x06F0, - _ => return c, - }; - let zero = match self { - Digits::Default => return c, - Digits::Arabic => 0x0030, - Digits::Hindi => 0x0660, - Digits::Farsi => 0x06F0, - Digits::Native => { - let l = language.to_ascii_lowercase(); - match () { - _ if l.starts_with("arabic") || l == "ar" || l.starts_with("ar-") => 0x0660, - _ if l.starts_with("persian") - || l.starts_with("farsi") - || l.starts_with("urdu") - || l == "fa" - || l.starts_with("fa-") - || l == "ur" - || l.starts_with("ur-") => - { - 0x06F0 - } - _ if l.starts_with("hindi") || l.starts_with("marathi") || l.starts_with("nepali") || l.starts_with("sanskrit") => 0x0966, - _ if l.starts_with("bengali") => 0x09E6, - _ if l.starts_with("gujarati") => 0x0AE6, - _ if l.starts_with("punjabi") => 0x0A66, - _ if l.starts_with("tamil") => 0x0BE6, - _ if l.starts_with("telugu") => 0x0C66, - _ if l.starts_with("kannada") => 0x0CE6, - _ if l.starts_with("malayalam") => 0x0D66, - _ if l.starts_with("thai") => 0x0E50, - _ if l.starts_with("lao") => 0x0ED0, - _ if l.starts_with("tibetan") => 0x0F20, - _ if l.starts_with("burmese") => 0x1040, - _ if l.starts_with("khmer") => 0x17E0, - _ => return c, - } - } - }; - char::from_u32(zero + d).unwrap_or(c) - } -} - -/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings? -/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps -/// text, never for a sentence's, a title's or an index heading's first letter. -pub fn capitalizes(c: char) -> bool { - !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') -} - -/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: -/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`) -/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking, -/// fallback fonts). A variant we don't know falls back to its language ("English: Australian" → -/// `en`); an unknown language (or "[No Language]") has none. Case, punctuation and IDML's `$ID/` -/// don't matter. Locale codes (`ll`, `ll_CC`, `ll-CC`, with a reform year after them as in -/// `de_DE_2006`) are their tag (`de-DE`), and Chinese also goes by its other common spellings -/// ("Simplified Chinese", "Chinese (Traditional)", "zh_CN", "zh-Hant"). -pub fn language_tag(language: &str) -> Option<&'static str> { - let language = language.trim(); - let language = language.strip_prefix("$ID/").unwrap_or(language); - let l = language.to_lowercase(); - let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); - let first = words.first().copied().unwrap_or(""); - let has = |any: &[&str]| words.iter().any(|w| any.contains(w)); - if first == "zh" || has(&["chinese"]) { - // The script decides; then the name; then the region. - return Some(if has(&["hans"]) { - "zh-Hans" - } else if has(&["hant"]) { - "zh-Hant" - } else if has(&["simplified", "cn", "sg"]) { - "zh-Hans" - } else if has(&["traditional", "tw", "hk", "mo"]) { - "zh-Hant" - } else { - "zh" - }); - } - if let Some(tag) = locale_code(&words) { - return intern_tag(tag); - } - let variant = match words.join(" ").as_str() { - "english usa" => Some("en-US"), - "english uk" => Some("en-GB"), - "english canadian" => Some("en-CA"), - "german swiss 2006 reform" | "german swiss" => Some("de-CH"), - "french swiss" => Some("fr-CH"), - "italian swiss" => Some("it-CH"), - "spanish castilian" => Some("es-ES"), - "french canadian" => Some("fr-CA"), - "portuguese brazilian" => Some("pt-BR"), - "norwegian bokmål" | "norwegian bokmal" => Some("nb"), - "norwegian nynorsk" => Some("nn"), - _ => None, - }; - if variant.is_some() { - return variant; - } - Some(match first { - "english" => "en", - "german" => "de", - "french" => "fr", - "spanish" => "es", - "catalan" => "ca", - "italian" => "it", - "portuguese" => "pt", - "dutch" => "nl", - "danish" => "da", - "swedish" => "sv", - "norwegian" => "no", - "finnish" => "fi", - "polish" => "pl", - "czech" => "cs", - "slovak" => "sk", - "hungarian" => "hu", - "romanian" => "ro", - "croatian" => "hr", - "lithuanian" => "lt", - "greek" => "el", - "russian" => "ru", - "ukrainian" => "uk", - "bulgarian" => "bg", - "serbian" => "sr", - "macedonian" => "mk", - "turkish" => "tr", - "japanese" => "ja", - "korean" => "ko", - "arabic" => "ar", - "persian" | "farsi" => "fa", - "urdu" => "ur", - "hebrew" => "he", - "georgian" => "ka", - _ => return None, - }) -} - -/// The tag of a locale code split into lowercase `words`: a language (2 or 3 letters), then -/// optionally a region (2 letters or 3 digits) and a reform year (4 digits) → `ll` or `ll-CC`. -fn locale_code(words: &[&str]) -> Option { - let letters = |w: &str, n: std::ops::RangeInclusive| n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()); - let digits = |w: &str, n: usize| w.len() == n && w.bytes().all(|b| b.is_ascii_digit()); - match *words { - [lang] if letters(lang, 2..=3) => Some(lang.to_string()), - [lang, region] | [lang, region, _] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) => { - if let [_, _, year] = *words - && !digits(year, 4) - { - return None; - } - Some(format!("{lang}-{}", region.to_ascii_uppercase())) - } - _ => None, - } -} - -/// `tag` as a `&'static str`: each distinct tag is kept once for the process. Language names come -/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`). -fn intern_tag(tag: String) -> Option<&'static str> { - const MAX_TAGS: usize = 512; - static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(t) = tags.iter().find(|t| **t == tag) { - return Some(t); - } - if tags.len() >= MAX_TAGS { - return None; - } - let t: &'static str = Box::leak(tag.into_boxed_str()); - tags.push(t); - Some(t) -} - -/// The language subtag of a BCP 47 tag (`de-DE` → `de`, `zh-Hant` → `zh`). -pub fn language_subtag(tag: &str) -> &str { - tag.split('-').next().unwrap_or(tag) -} - -/// A tracked change on text. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ChangeMark { - #[default] - None, - Inserted, - Deleted, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "value")] -pub enum Kerning { - #[default] - Metrics, - Optical, - /// Zero ("0" in the kerning menu). - None, - /// Manual pair kerning in 1/1000 em (applies after this character). - Manual(f64), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Capitalization { - #[default] - Normal, - AllCaps, - SmallCaps, - OpenTypeAllSmallCaps, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Position { - #[default] - Normal, - Superscript, - Subscript, - OtSuperscript, - OtSubscript, - OtNumerator, - OtDenominator, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Align { - #[default] - Left, - Center, - Right, - LeftJustified, - CenterJustified, - RightJustified, - FullyJustified, - TowardsSpine, - AwayFromSpine, -} - -impl Align { - pub fn is_justified(self) -> bool { - matches!(self, Align::LeftJustified | Align::CenterJustified | Align::RightJustified | Align::FullyJustified) - } - pub const ALL: [Align; 9] = [ - Align::Left, - Align::Center, - Align::Right, - Align::LeftJustified, - Align::CenterJustified, - Align::RightJustified, - Align::FullyJustified, - Align::TowardsSpine, - Align::AwayFromSpine, - ]; - pub fn label(self) -> &'static str { - match self { - Align::Left => "Align left", - Align::Center => "Align center", - Align::Right => "Align right", - Align::LeftJustified => "Justify with last line aligned left", - Align::CenterJustified => "Justify with last line aligned center", - Align::RightJustified => "Justify with last line aligned right", - Align::FullyJustified => "Justify all lines", - Align::TowardsSpine => "Align towards spine", - Align::AwayFromSpine => "Align away from spine", - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Composer { - /// Total-fit (Knuth–Plass) over the whole paragraph. - #[default] - Paragraph, - /// Greedy, line by line. - SingleLine, - /// The Japanese paragraph composer: total fit, with the CJK leading model and character - /// alignment. - Japanese, - /// The Japanese single-line composer. - JapaneseSingleLine, -} - -impl Composer { - /// Breaks lines greedily, one at a time. - pub fn single_line(self) -> bool { - matches!(self, Composer::SingleLine | Composer::JapaneseSingleLine) - } - - /// A Japanese composer: lines follow the leading model ([`crate::cjk::LeadingModel`]) and - /// characters their [`crate::cjk::CharacterAlignment`]. The others set lines baseline to - /// baseline with every character on the Roman baseline. - pub fn japanese(self) -> bool { - matches!(self, Composer::Japanese | Composer::JapaneseSingleLine) - } -} - -/// What a nested style runs to (Drop Caps and Nested Styles). -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "chars")] -pub enum NestedUntil { - Sentences, - #[default] - Words, - Characters, - Letters, - Digits, - Tab, - ForcedLineBreak, - EmSpace, - EnSpace, - /// The paragraph's drop cap characters. - Dropcap, - /// Any of these characters. - Chars(String), -} - -/// A character style applied from the start of the paragraph through (or up to) the `count`-th -/// delimiter; nested styles follow each other. -/// A nested line style: `style` for the next `lines` lines. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct NestedLineStyle { - pub style: String, - pub lines: u32, -} - -impl Default for NestedLineStyle { - fn default() -> Self { - NestedLineStyle { style: String::new(), lines: 1 } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct NestedStyle { - pub style: String, - /// Through (include the delimiter) or up to (stop before it). - pub through: bool, - pub count: u32, - pub until: NestedUntil, -} - -impl Default for NestedStyle { - fn default() -> Self { - NestedStyle { style: String::new(), through: true, count: 1, until: NestedUntil::Words } - } -} - -impl NestedStyle { - /// The drop cap's character style as a nested style: `style` through 1 drop cap. - pub fn drop_cap(style: &str) -> Self { - NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap } - } - - /// Is this the form a drop cap's character style takes at the start of a nested style list? - pub fn is_drop_cap(&self) -> bool { - self.until == NestedUntil::Dropcap && self.through && self.count == 1 - } -} - -/// A character style applied to every match of a regular expression in the paragraph. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct GrepStyle { - pub style: String, - pub pattern: String, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum GridAlign { - #[default] - None, - AllLines, - FirstLineOnly, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StartParagraph { - #[default] - Anywhere, - NextColumn, - NextFrame, - NextPage, - NextOddPage, - NextEvenPage, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "columns")] -pub enum SpanColumns { - #[default] - Single, - /// Span `n` columns (0 = all). - Span(u32), - /// Split into `n` sub-columns. - Split(u32), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TabAlign { - #[default] - Left, - Center, - Right, - /// Align on a character (default `.`). - Char, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TabStop { - /// A file that held a non-finite position (written as `null`) reads it back as NaN, which - /// [`TabStop::sanitized_list`] drops. - #[serde(deserialize_with = "position_or_nan")] - pub position: f64, - #[serde(default)] - pub align: TabAlign, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub leader: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub align_on: String, -} - -/// The colour of a paragraph rule that follows the paragraph's text colour. -pub const TEXT_COLOR: &str = "Text Color"; - -/// Most stops a paragraph holds. -pub const MAX_TAB_STOPS: usize = 100; -/// Furthest stop position: 216 in, the largest page. -pub const MAX_TAB_POSITION: f64 = 15_552.0; -/// Longest leader, in characters. -pub const MAX_TAB_LEADER: usize = 8; -/// Stops closer than this share a position. -pub const SAME_TAB_POSITION: f64 = 0.01; - -/// Smallest type size the engine accepts, in points. -pub const MIN_TYPE_SIZE: f64 = 0.1; -/// Largest type size the engine accepts, in points, as InDesign's own field allows. -pub const MAX_TYPE_SIZE: f64 = 1296.0; -/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab -/// stop, which is the widest page. -pub const MAX_MEASURE: f64 = MAX_TAB_POSITION; - -/// A type size read from a file, or `None` when it is not a size the engine can mean. -/// -/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer -/// had its own spelling of this test, or only checked that the value was finite -- so tagged text -/// took `` and set a 1e300 pt size on the text (#1593). A size outside the range is -/// dropped rather than clamped, so the text keeps the size its style gives it. -pub fn imported_type_size(pt: f64) -> Option { - (pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt) -} - -/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the -/// value is not finite. -/// -/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything" -/// still indents to the widest page the engine has, where dropping the value would quietly -/// restore the style's own indent and hide what the file asked for (#1593). -pub fn imported_measure(pt: f64) -> Option { - pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE)) -} - -impl TabStop { - /// A finite position clamped to `0..=MAX_TAB_POSITION`. - pub fn clamp_position(v: f64) -> Result { - if v.is_finite() { Ok(v.clamp(0.0, MAX_TAB_POSITION)) } else { Err("position must be a finite number".into()) } - } - - /// A leader: up to [`MAX_TAB_LEADER`] characters, no control characters. - pub fn check_leader(v: &str) -> Result<(), String> { - if v.chars().count() > MAX_TAB_LEADER || v.chars().any(char::is_control) { - return Err(format!("`leader`: at most {MAX_TAB_LEADER} printable characters")); - } - Ok(()) - } - - /// An align-on character: one printable character or none (a decimal point). - pub fn check_align_on(v: &str) -> Result<(), String> { - if v.chars().count() > 1 || v.chars().any(char::is_control) { - return Err("`alignOn`: one printable character".into()); - } - Ok(()) - } - - /// A tab-stop list as the Tabs commands keep it: at most [`MAX_TAB_STOPS`] stops, positions - /// clamped, leaders and align-on characters checked, a char stop aligning on `.` unless it - /// names a character, sorted by position, and one stop per position (the later one wins). - pub fn checked_list(mut tabs: Vec) -> Result, String> { - if tabs.len() > MAX_TAB_STOPS { - return Err(format!("a paragraph holds at most {MAX_TAB_STOPS} tab stops")); - } - for t in &mut tabs { - t.position = Self::clamp_position(t.position)?; - Self::check_leader(&t.leader)?; - Self::check_align_on(&t.align_on)?; - } - Ok(Self::normalized(tabs)) - } - - /// A tab-stop list read from a file, repaired to what [`checked_list`](Self::checked_list) - /// accepts: stops with a non-finite position dropped, positions clamped, control characters - /// removed from leaders and align-on characters and both cut to their limits, sorted, one stop - /// per position (the later one wins), and the first [`MAX_TAB_STOPS`] by position kept. - pub fn sanitized_list(tabs: Vec) -> Vec { - let tabs = tabs - .into_iter() - .filter_map(|mut t| { - t.position = Self::clamp_position(t.position).ok()?; - if Self::check_leader(&t.leader).is_err() { - t.leader = t.leader.chars().filter(|c| !c.is_control()).take(MAX_TAB_LEADER).collect(); - } - if Self::check_align_on(&t.align_on).is_err() { - t.align_on = t.align_on.chars().filter(|c| !c.is_control()).take(1).collect(); - } - Some(t) - }) - .collect(); - let mut out = Self::normalized(tabs); - out.truncate(MAX_TAB_STOPS); - out - } - - /// Finite, clamped stops sorted by position, one per position: of stops closer than - /// [`SAME_TAB_POSITION`], the later one in `tabs` wins. A char stop with no character aligns - /// on `.`. - fn normalized(tabs: Vec) -> Vec { - let mut stops: Vec<(usize, TabStop)> = tabs.into_iter().enumerate().collect(); - stops.sort_by(|a, b| a.1.position.total_cmp(&b.1.position)); - let mut out: Vec<(usize, TabStop)> = Vec::with_capacity(stops.len()); - for (i, mut t) in stops { - if t.align == TabAlign::Char && t.align_on.is_empty() { - t.align_on = ".".into(); - } - match out.last_mut() { - Some(last) if t.position - last.1.position < SAME_TAB_POSITION => { - if i > last.0 { - *last = (i, t); - } - } - _ => out.push((i, t)), - } - } - out.into_iter().map(|(_, t)| t).collect() - } -} - -/// A stop's position; `null` (how JSON writes a non-finite number) reads as NaN. -fn position_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) -} - -/// A paragraph's tab-stop list in either attribute form, as read from a file. -trait FileTabs: Sized { - fn sanitized(self) -> Self; -} - -impl FileTabs for Vec { - fn sanitized(self) -> Self { - TabStop::sanitized_list(self) - } -} - -impl FileTabs for Option> { - fn sanitized(self) -> Self { - self.map(TabStop::sanitized_list) - } -} - -/// Deserializes a `tabs` field through [`TabStop::sanitized_list`]: a saved document's tab lists -/// get the limits the Tabs commands keep. -fn sanitized_tabs<'de, D: serde::Deserializer<'de>, T: Deserialize<'de> + FileTabs>(d: D) -> Result { - T::deserialize(d).map(FileTabs::sanitized) -} - -/// A paragraph rule (Rule Above / Rule Below). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Rule { - pub on: bool, - pub weight: f64, - /// A swatch, or [`TEXT_COLOR`]. - pub color: String, - pub tint: f32, - /// Width: column (true) or text (false). - pub column_width: bool, - pub offset: f64, - pub left_indent: f64, - pub right_indent: f64, -} - -impl Default for Rule { - fn default() -> Self { - Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } - } -} - -/// How wide a paragraph border or shading box is: the column, or the text's longest line. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BoxWidth { - #[default] - Column, - Text, -} - -/// Where a paragraph border or shading box starts above the first line (before its top offset). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BoxTop { - /// The tallest ascent on the line. - #[default] - Ascent, - Baseline, - /// The top of the em box. - EmBox, - /// The top of the line's leading. - Leading, -} - -/// Where a paragraph border or shading box ends below the last line (before its bottom offset). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BoxBottom { - /// The deepest descent on the line. - #[default] - Descent, - Baseline, - /// The bottom of the em box. - EmBox, -} - -/// Where a bullet or number sits against the first line's start (the left and first-line -/// indents): it starts there, is centred on it, or ends there. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ListAlign { - #[default] - Left, - Center, - Right, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ListType { - #[default] - None, - Bullets, - Numbers, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NumberStyle { - #[default] - Arabic, - UpperRoman, - LowerRoman, - UpperLetters, - LowerLetters, - /// 01, 02, … - ArabicLeadingZero, - /// 001, 002, … - ArabicThreeDigits, - /// 0001, 0002, … - ArabicFourDigits, - /// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes. - Symbols, - /// 一, 二, 三, …: Chinese numerals written digit by digit, so 10 is 一〇 and 38 is 三八. - Kanji, -} - -/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman -/// numerals, letters, symbols), so a hostile start number can't build a huge label. -const MAX_REPEATED_FORM: u32 = 3999; - -impl NumberStyle { - pub fn format(self, n: u32) -> String { - if n > MAX_REPEATED_FORM - && matches!( - self, - NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols - ) - { - return n.to_string(); - } - match self { - NumberStyle::Arabic => n.to_string(), - NumberStyle::ArabicLeadingZero => format!("{n:02}"), - NumberStyle::ArabicThreeDigits => format!("{n:03}"), - NumberStyle::ArabicFourDigits => format!("{n:04}"), - NumberStyle::UpperRoman => roman(n), - NumberStyle::LowerRoman => roman(n).to_lowercase(), - NumberStyle::UpperLetters => letters(n), - NumberStyle::LowerLetters => letters(n).to_lowercase(), - NumberStyle::Symbols => { - const SYM: [char; 6] = ['*', '†', '‡', '§', '¶', '‖']; - let n = n.max(1) as usize - 1; - std::iter::repeat_n(SYM[n % SYM.len()], n / SYM.len() + 1).collect() - } - NumberStyle::Kanji => kanji_digits(n), - } - } -} - -/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …), -/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no -/// meaning in a label are dropped; a label never holds a paragraph break. -pub fn expand_list_text(s: &str, number: &str) -> String { - expand_list_levels(s, number, &[]) -} - -/// [`expand_list_text`] with `levels[k]` in for `^1`…`^9` (level k + 1's current number; a level -/// past the end is dropped). -pub fn expand_list_levels(s: &str, number: &str, levels: &[String]) -> String { - let mut out = String::with_capacity(s.len() + number.len()); - let mut it = s.chars(); - while let Some(c) = it.next() { - if c != '^' { - if c != '\n' && c != '\r' { - out.push(c); - } - continue; - } - let Some(m) = it.next() else { - out.push('^'); - break; - }; - let r = match m { - '#' => { - out.push_str(number); - continue; - } - d @ '1'..='9' => { - let k = d as usize - '1' as usize; - if let Some(l) = levels.get(k) { - out.push_str(l); - } - continue; - } - '^' => '^', - 't' => '\t', - 'y' => crate::story::RIGHT_INDENT_TAB, - 'i' => crate::story::INDENT_HERE, - 'n' => crate::story::FORCED_LINE_BREAK, - 'm' => '\u{2003}', - '>' => '\u{2002}', - 'f' => '\u{2001}', - '|' => '\u{200A}', - '<' => '\u{2009}', - '/' => '\u{2007}', - '.' => '\u{2008}', - 's' => '\u{A0}', - 'S' => '\u{202F}', - '_' => '\u{2014}', - '=' => '\u{2013}', - '~' => '\u{2011}', - '{' => '\u{201C}', - '}' => '\u{201D}', - '[' => '\u{2018}', - ']' => '\u{2019}', - '"' => '"', - '\'' => '\'', - _ => continue, - }; - out.push(r); - } - out -} - -fn kanji_digits(n: u32) -> String { - const DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; - n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| DIGITS.get(d as usize)).copied().unwrap_or(c)).collect() -} - -fn roman(mut n: u32) -> String { - if n == 0 { - return "0".into(); - } - let t = [ - (1000, "M"), - (900, "CM"), - (500, "D"), - (400, "CD"), - (100, "C"), - (90, "XC"), - (50, "L"), - (40, "XL"), - (10, "X"), - (9, "IX"), - (5, "V"), - (4, "IV"), - (1, "I"), - ]; - let mut s = String::new(); - for (v, r) in t { - while n >= v { - s.push_str(r); - n -= v; - } - } - s -} - -/// A, B, …, Z, AA, BB, … (InDesign's letter numbering repeats the letter). -fn letters(n: u32) -> String { - if n == 0 { - return String::new(); - } - let i = (n - 1) % 26; - let reps = (n - 1) / 26 + 1; - std::iter::repeat_n(char::from(b'A' + i as u8), reps as usize).collect() -} - -attr_set! { - /// Character attributes (Character panel / character styles / the character part of paragraph styles). - CharAttrs / CharProps { - font_family: String = "Source Serif 4".into(), - font_style: String = "Regular".into(), - /// Point size. - size: f64 = 12.0, - leading: Leading = Leading::Auto, - kerning: Kerning = Kerning::Metrics, - /// Tracking in 1/1000 em. - tracking: f64 = 0.0, - /// Explicit CJK aki, in em; None restores automatic spacing. - leading_aki: Option = None => "deserialize_explicit_value", - trailing_aki: Option = None => "deserialize_explicit_value", - /// Proportional character compression, 0..1. - tsume: f64 = 0.0, - /// Target em cells for the contiguous formatted group (0 disables jidori). - jidori: u32 = 0, - character_alignment: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, - leading_model: crate::cjk::LeadingModel = crate::cjk::LeadingModel::Roman, - /// The kenten mark's text: a custom mark, or the preset of `kenten_kind` (see - /// [`crate::cjk_settings::kenten_mark`]); empty is the sesame dot. - kenten_character: String = String::new(), - /// Horizontal / vertical scale, 1.0 = 100%. - h_scale: f64 = 1.0, - v_scale: f64 = 1.0, - baseline_shift: f64 = 0.0, - /// Skew (false italic), degrees. - skew: f64 = 0.0, - fill: String = "[Black]".into(), - fill_tint: f32 = 1.0, - stroke: String = "[None]".into(), - stroke_tint: f32 = 1.0, - stroke_weight: f64 = 1.0, - /// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the - /// letters at its full weight, so the letters keep their shape. Off, it straddles the outline. - stroke_behind_fill: bool = false, - capitalization: Capitalization = Capitalization::Normal, - position: Position = Position::Normal, - underline: bool = false, - strikethrough: bool = false, - /// Underline Options: weight and offset below the baseline (`None` = from the size), - /// colour (empty = the text colour) and tint. - underline_weight: Option = None => "deserialize_explicit_value", - underline_offset: Option = None => "deserialize_explicit_value", - underline_color: String = String::new(), - underline_tint: f32 = 1.0, - /// Strikethrough Options: offset above the baseline. - strikethrough_weight: Option = None => "deserialize_explicit_value", - strikethrough_offset: Option = None => "deserialize_explicit_value", - strikethrough_color: String = String::new(), - strikethrough_tint: f32 = 1.0, - glyph_form: String = String::new(), - ligatures: bool = true, - no_break: bool = false, - /// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line. - tate_chu_yoko: bool = false, - tate_chu_yoko_x_offset: f64 = 0.0, - tate_chu_yoko_y_offset: f64 = 0.0, - /// Ruby: the reading set small above the text (to its right in vertical text). Group ruby - /// sets it over the whole run; per-character ruby holds one reading per character, - /// separated by U+3000. Its other settings are the `ruby_*` fields below. - ruby: String = String::new(), - /// Kenten: an emphasis dot above each character. - kenten: bool = false, - /// Warichu: the run is set in smaller lines stacked inside one line height. - warichu: bool = false, - /// How many warichu lines (InDesign's minimum is 2). - warichu_lines: u32 = 2, - /// Warichu size as a percentage of the parent size. - warichu_size: f64 = 50.0, - /// Extra points between warichu baselines. 0 is automatic (one small em). - warichu_line_spacing: f64 = 0.0, - warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto, - /// Minimum characters on a warichu line before a break, and on the line after it. - warichu_chars_before_break: u32 = 1, - warichu_chars_after_break: u32 = 1, - // Kenten Settings / Kenten Color (`kenten` switches the marks on). - /// The mark; [`crate::cjk_settings::KentenKind::Custom`] draws `kenten_character`. - kenten_kind: crate::cjk_settings::KentenKind = crate::cjk_settings::KentenKind::SesameDot, - /// Font of a custom mark (empty = the text's font) and its style. - kenten_font: String = String::new(), - kenten_font_style: String = String::new(), - /// Mark size in points; `None` is half the text size. - kenten_size: Option = None, - /// Horizontal / vertical scale of the mark, 1.0 = 100%. - kenten_x_scale: f64 = 1.0, - kenten_y_scale: f64 = 1.0, - /// Points between the text's em box and the mark's. - kenten_distance: f64 = 0.0, - kenten_position: crate::cjk_settings::KentenPosition = crate::cjk_settings::KentenPosition::AboveRight, - kenten_alignment: crate::cjk_settings::KentenAlignment = crate::cjk_settings::KentenAlignment::Center, - /// How a custom mark was entered (IDML `KentenCharacterSet`), kept for interchange. - kenten_character_set: String = String::new(), - /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = - /// the text's). - kenten_fill: String = String::new(), - kenten_fill_tint: Option = None, - kenten_stroke: String = String::new(), - kenten_stroke_tint: Option = None, - kenten_stroke_weight: Option = None, - kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. - /// Compression in percent of the em (0 = off). - shatai_magnification: f64 = 0.0, - /// Degrees from the line direction, counter-clockwise. - shatai_angle: f64 = 45.0, - /// Turn the compressed character back so its edge along the line stays along the line. - shatai_adjust_rotation: bool = false, - /// Fit the advance to the compressed em box. - shatai_adjust_tsume: bool = true, - // Ruby settings (see [`crate::ruby`]). Defaults are the IDML text defaults. - ruby_type: crate::ruby::RubyType = crate::ruby::RubyType::Group, - /// How a ruby shorter than its parent is spread over it. - ruby_alignment: crate::ruby::RubyAlignment = crate::ruby::RubyAlignment::Jis, - ruby_position: crate::ruby::RubyPosition = crate::ruby::RubyPosition::AboveRight, - /// Points along the line, and away from the parent text across it. - ruby_x_offset: f64 = 0.0, - ruby_y_offset: f64 = 0.0, - /// Empty: the parent's font and style. - ruby_font: String = String::new(), - ruby_font_style: String = String::new(), - /// Points; `None` is half the parent size. - ruby_font_size: Option = None, - /// 1.0 = 100%. - ruby_x_scale: f64 = 1.0, - ruby_y_scale: f64 = 1.0, - /// Use the font's ruby glyphs (OpenType `ruby`). - ruby_open_type_pro: bool = true, - /// Tate-chu-yoko in vertical ruby for runs of up to this many digits (0 = off). - ruby_auto_tcy_digits: u32 = 0, - ruby_auto_tcy_include_roman: bool = false, - ruby_auto_tcy_auto_scale: bool = true, - /// IDML `RubyOverhang`, kept for round trips; `ruby_overhang_amount` sets the overhang. - ruby_overhang: bool = false, - ruby_overhang_amount: crate::ruby::RubyOverhang = crate::ruby::RubyOverhang::OneRuby, - ruby_parent_spacing: crate::ruby::RubyParentSpacing = crate::ruby::RubyParentSpacing::Aki121, - /// A longer ruby at a line's start or end is set flush with the line edge. - ruby_auto_align: bool = true, - /// A longer ruby is narrowed first, down to `ruby_scaling_min` (1.0 = 100%). - ruby_auto_scaling: bool = false, - ruby_scaling_min: f64 = 0.66, - /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. - ruby_fill: String = String::new(), - ruby_fill_tint: Option = None, - ruby_stroke: String = String::new(), - ruby_stroke_tint: Option = None, - ruby_stroke_weight: Option = None, - ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - /// Digits (World-Ready): how 0–9 are drawn. - digits: Digits = Digits::Default, - character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default, - /// Character-level permission, additionally gated by paragraph kashidas. - allow_kashidas: bool = true, - diacritic_position: crate::arabic::DiacriticPosition = crate::arabic::DiacriticPosition::Default, - /// Additional mark offsets, in thousandths of an em. Positive y is up. - diacritic_x_offset: f64 = 0.0, - diacritic_y_offset: f64 = 0.0, - /// IDML contextual-form request, separate from general OpenType features. - positional_form: String = String::new(), - language: String = "English: USA".into(), - /// Additional OpenType features, e.g. `["onum", "ss01"]`. - otf_features: Vec = Vec::new(), - /// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text). - conditions: Vec = Vec::new(), - /// Track Changes: text added or deleted while tracking (deleted text isn't laid out). - change: ChangeMark = ChangeMark::None, - /// XML: the element this text is tagged with (Tags panel, inline). - xml_tag: String = String::new(), - } -} - -attr_set! { - /// Paragraph attributes (Paragraph panel / paragraph styles). - ParaAttrs / ParaProps { - align: Align = Align::Left, - /// Paragraph Direction (World-Ready): the base direction lines are ordered in. - direction: TextDirection = TextDirection::LeftToRight, - left_indent: f64 = 0.0, - right_indent: f64 = 0.0, - first_line_indent: f64 = 0.0, - last_line_indent: f64 = 0.0, - space_before: f64 = 0.0, - space_after: f64 = 0.0, - /// Space between consecutive paragraphs of the same style; replaces space after/before there (None = ignore). - same_para_style_spacing: Option = None => "deserialize_explicit_value", - /// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never - /// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the - /// character style `drop_cap_style` ("" or `[None]`: the text's own formatting). - drop_cap_lines: u32 = 0, - drop_cap_chars: u32 = 0, - drop_cap_style: String = String::new(), - /// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side - /// bearing is taken off), and the lines beside it start where its advance ends. - drop_cap_align_left: bool = true, - /// Scale for Descenders (kept and round-tripped; doesn't change the layout). - drop_cap_scale_descenders: bool = false, - /// Nested styles, in order from the paragraph start. - nested_styles: Vec = Vec::new(), - /// GREP styles: a character style for every match of a pattern (applied after nested). - grep_styles: Vec = Vec::new(), - /// Nested line styles: a character style for the paragraph's first lines, in order. - nested_line_styles: Vec = Vec::new(), - grid_align: GridAlign = GridAlign::None, - composer: Composer = Composer::Paragraph, - // Hyphenation - hyphenate: bool = true, - hyph_min_word: u32 = 5, - hyph_after_first: u32 = 2, - hyph_before_last: u32 = 2, - /// Max consecutive hyphenated lines (0 = unlimited). - hyph_limit: u32 = 3, - hyph_zone: f64 = 36.0, - hyph_capitalized: bool = true, - hyph_last_word: bool = true, - hyph_across_column: bool = true, - /// Better spacing (0) ↔ fewer hyphens (1). - hyph_weight: f64 = 0.5, - // Justification (percent as fractions) - word_space_min: f64 = 0.8, - word_space_desired: f64 = 1.0, - word_space_max: f64 = 1.33, - letter_space_min: f64 = 0.0, - letter_space_desired: f64 = 0.0, - letter_space_max: f64 = 0.0, - glyph_scale_min: f64 = 1.0, - glyph_scale_desired: f64 = 1.0, - glyph_scale_max: f64 = 1.0, - auto_leading: f64 = 1.2, - single_word_justify: Align = Align::FullyJustified, - /// Insert Kashidas (World-Ready): justified Arabic lines stretch at joins before spaces. - kashidas: bool = true, - /// Preserved vendor policy; non-default policies require a dedicated composer. - arabic_justification: String = String::new(), - paragraph_kashida_width: Option = None => "deserialize_explicit_value", - /// Korean text breaks between any two syllables (character-based breaking); otherwise - /// it breaks at spaces, word by word. - korean_char_breaks: bool = false, - // Keeps - keep_with_next: u32 = 0, - keep_lines_together: bool = false, - /// All lines in paragraph (true) or at start/end of paragraph (false). - keep_all_lines: bool = false, - keep_first: u32 = 2, - keep_last: u32 = 2, - start_paragraph: StartParagraph = StartParagraph::Anywhere, - span_columns: SpanColumns = SpanColumns::Single, - /// Split Columns: the space between sub-columns and the inset from the column's edges. - split_inside_gutter: f64 = 6.0, - split_outside_gutter: f64 = 0.0, - rule_above: Rule = Rule::default(), - rule_below: Rule = Rule::default(), - #[serde(deserialize_with = "sanitized_tabs")] - tabs: Vec = Vec::new(), - list_type: ListType = ListType::None, - /// Named list (Define Lists) the numbers belong to ("" = the story's own list). - list_name: String = String::new(), - /// Start At: restart the numbering at this number. - start_at: Option = None => "deserialize_explicit_value", - bullet_char: String = "\u{2022}".into(), - number_style: NumberStyle = NumberStyle::Arabic, - /// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in - /// [`expand_list_text`]. - number_expression: String = "^#.^t".into(), - /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). - list_separator: String = "\t".into(), - /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away - /// again with the list (an indent the paragraph had before stays). - list_hang: bool = false, - balance_ragged: bool = false, - /// Hang punctuation outside the measure in this paragraph, at each character's own size - /// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on - /// overrides it with the story's size). - optical_margin: bool = false, - /// Ignore Optical Margin: no hang in this paragraph, whatever the story or - /// `optical_margin` say. - ignore_optical_margin: bool = false, - /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. - kinsoku: Option = None => "deserialize_explicit_value", - /// Preserved spacing-table reference; compatibility audit reports unsupported composition. - mojikumi: String = String::new(), - /// Preserved priority policy; the current breaker does not implement these priorities. - kinsoku_type: String = String::new(), - kinsoku_hang: crate::cjk::KinsokuHang = crate::cjk::KinsokuHang::None, - bunri_kinshi: bool = false, - rensuuji: bool = true, - treat_ideographic_space_as_space: bool = false, - // Auto Tate-chu-yoko, Japanese Composition and Grid Settings. - /// Auto tate-chu-yoko: in vertical text, runs of up to this many half-width digits are set - /// across one em (0 = off). - auto_tcy: u32 = 0, - /// Auto tate-chu-yoko also takes runs of half-width roman letters. - auto_tcy_include_roman: bool = false, - /// Rotate Roman Characters in Vertical Text (縦組み中の欧文回転): half-width characters - /// stand upright one by one instead of lying along the line. - rotate_roman: bool = false, - /// Roman word break (欧文泣き別れ): roman words may break between any two letters, - /// without a hyphen. - roman_word_break: bool = false, - /// The point of a line that sits on the grid while `grid_align` is on. - grid_reference: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, - /// Gyoudori (行取り): grid lines each line takes (0 = as many as its leading needs). - grid_gyoudori: u32 = 0, - /// Paragraph gyoudori: the paragraph as a whole, not each line, takes `grid_gyoudori` lines. - paragraph_gyoudori: bool = false, - /// Paragraph shading, and how far it reaches past the text: top, left, bottom, right. - shading_on: bool = false, - shading_color: String = "[Black]".into(), - shading_tint: f32 = 0.2, - shading_offsets: [f64; 4] = [0.0; 4], - /// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the - /// text (top, left, bottom, right). - border_on: bool = false, - border_weights: [f64; 4] = [1.0; 4], - border_color: String = "[Black]".into(), - border_tint: f32 = 1.0, - border_offsets: [f64; 4] = [0.0; 4], - /// The border's stroke type (a document stroke style by name, or a built-in type), cap and - /// join. - border_type: crate::item::StrokeType = crate::item::StrokeType::Solid, - border_cap: crate::item::Cap = crate::item::Cap::Butt, - border_join: crate::item::Join = crate::item::Join::Miter, - /// Corner shape and size: top left, top right, bottom right, bottom left. - border_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], - border_width: BoxWidth = BoxWidth::Column, - border_top: BoxTop = BoxTop::Ascent, - border_bottom: BoxBottom = BoxBottom::Descent, - /// A paragraph split across frames or columns gets a closed box in each part (otherwise the - /// box is open where it splits). - border_display_if_splits: bool = false, - /// Consecutive paragraphs with the same border and shading share one box. - border_merge: bool = true, - /// The colour between the dashes, dots or stripes of a border that isn't solid. - border_gap_color: String = "[None]".into(), - border_gap_tint: f32 = 1.0, - /// Shading corners: top left, top right, bottom right, bottom left. - shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], - shading_width: BoxWidth = BoxWidth::Column, - shading_top: BoxTop = BoxTop::Ascent, - shading_bottom: BoxBottom = BoxBottom::Descent, - /// The shading stops at the edges of the text frame. - shading_clip: bool = false, - /// The shading shows on screen only: not printed or exported. - shading_nonprinting: bool = false, - /// Least space between a spanning or split paragraph and the column text above and below it. - span_space_before: f64 = 0.0, - span_space_after: f64 = 0.0, - /// List level (1–9): each level numbers on its own, and `^1`…`^9` in a number's text are the - /// current numbers of those levels. - list_level: u32 = 1, - /// Character styles of the bullet and of the number. - bullet_char_style: String = crate::story::NO_CHAR_STYLE.into(), - number_char_style: String = crate::story::NO_CHAR_STYLE.into(), - bullet_align: ListAlign = ListAlign::Left, - number_align: ListAlign = ListAlign::Left, - /// A tab stop for the text after the bullet or number, from the column's left edge. - list_tab: Option = None, - /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise - /// that level) starts this level's numbering again. - restart_numbers: bool = true, - restart_after_level: u32 = 0, - /// The bullet's font family and style ("" = the paragraph's). - bullet_font: String = String::new(), - bullet_font_style: String = String::new(), - } -} - -impl ParaAttrs { - /// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the - /// rule fields to change; the others keep their values in `current` (the resolved attributes - /// of the paragraph or style being edited). A `tabs` list is checked and sorted as - /// [`TabStop::checked_list`] does. - pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> { - let rule = match key.chars().filter(|c| *c != '_').collect::().to_ascii_lowercase().as_str() { - "ruleabove" => ¤t.rule_above, - "rulebelow" => ¤t.rule_below, - "tabs" if !value.is_null() => { - // Count before deserializing: the list may be arbitrarily long. - if value.as_array().is_some_and(|a| a.len() > MAX_TAB_STOPS) { - return Err(format!("{key}: a paragraph holds at most {MAX_TAB_STOPS} tab stops")); - } - let tabs: Vec = serde_json::from_value(value.clone()).map_err(|e| format!("{key}: {e}"))?; - self.tabs = Some(TabStop::checked_list(tabs).map_err(|e| format!("{key}: {e}"))?); - return Ok(()); - } - _ => return self.set_json(key, value), - }; - let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; - let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; - let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; - for (k, v) in patch { - if !fields.contains_key(k) { - return Err(format!("{key}: unknown rule field `{k}`")); - } - fields.insert(k.clone(), v.clone()); - } - self.set_json(key, &merged) - } -} - -impl ParaProps { - /// The label of a numbered paragraph with number `n`. - pub fn number_label(&self, n: u32) -> String { - expand_list_text(&self.number_expression, &self.number_style.format(n)) - } - - /// The label of a numbered paragraph that `list` has just numbered ([`ListCounter::advance`]). - pub fn number_label_in(&self, list: &ListCounter) -> String { - let level = list_level_index(self.list_level); - let number = list.labels.get(level).map_or("", String::as_str); - expand_list_levels(&self.number_expression, number, &list.labels) - } - - /// The label of a bulleted paragraph. - pub fn bullet_label(&self) -> String { - let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect(); - s.push_str(&expand_list_text(&self.list_separator, "")); - s - } -} - -/// Levels a list can have. -pub const LIST_LEVELS: usize = 9; - -/// Index (0-based) of list level `level` (1-based, clamped to 1–[`LIST_LEVELS`]). -fn list_level_index(level: u32) -> usize { - (level.clamp(1, LIST_LEVELS as u32) - 1) as usize -} - -/// The numbering of one list as it goes: each level's last number and its text, and when each -/// level last numbered a paragraph (for Restart Numbers at This Level After). -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] -pub struct ListCounter { - numbers: [u32; LIST_LEVELS], - labels: [String; LIST_LEVELS], - seen: [u64; LIST_LEVELS], - tick: u64, -} - -impl ListCounter { - /// Number the next paragraph of `pp` (a numbered paragraph with text) and return its number. - pub fn advance(&mut self, pp: &ParaProps) -> u32 { - let l = list_level_index(pp.list_level); - self.tick = self.tick.saturating_add(1); - let since = self.seen[l]; - let restart = pp.restart_numbers - && match pp.restart_after_level { - 0 => self.seen[..l].iter().any(|&t| t > since), - m => (m as usize) <= l && self.seen.get(m as usize - 1).is_some_and(|&t| t > since), - }; - let n = match pp.start_at { - Some(s) => s.max(1), - None if restart => 1, - None => self.numbers[l].saturating_add(1), - }; - self.numbers[l] = n; - self.labels[l] = pp.number_style.format(n); - self.seen[l] = self.tick; - n - } -} - #[cfg(test)] -mod tests { - use super::*; - - /// #777: an unknown attribute names the nearest one, or lists them all. - #[test] - fn unknown_attributes_suggest_the_right_name() { - let mut c = CharAttrs::default(); - assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?"); - assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?"); - let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err(); - assert!( - all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"), - "{all}" - ); - assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok()); - let mut p = ParaAttrs::default(); - assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?"); - assert_eq!(camel("first_line_indent"), "firstLineIndent"); - } - - #[test] - fn levels_number_on_their_own_and_restart_after_a_higher_level() { - let at = - |level: u32| ParaProps { list_type: ListType::Numbers, list_level: level, number_expression: "^1.^#".into(), ..ParaProps::default() }; - let mut c = ListCounter::default(); - let mut labels = vec![]; - for level in [1, 2, 2, 1, 2] { - let pp = at(level); - c.advance(&pp); - labels.push(pp.number_label_in(&c)); - } - assert_eq!(labels, ["1.1", "1.1", "1.2", "2.2", "2.1"]); - // Without the restart, level 2 carries on. - let mut c = ListCounter::default(); - let keep = |level: u32| ParaProps { restart_numbers: false, ..at(level) }; - let n: Vec = [1, 2, 1, 2].into_iter().map(|l| c.advance(&keep(l))).collect(); - assert_eq!(n, [1, 1, 2, 2]); - } - - #[test] - fn a_partial_rule_keeps_the_other_fields() { - let current = ParaProps { - rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() }, - ..ParaProps::default() - }; - let mut a = ParaAttrs::default(); - a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), ¤t).unwrap(); - assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() })); - assert_eq!(a.rule_above, None); - assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), ¤t).is_err(), "unknown field"); - a.set_json_over("ruleBelow", &serde_json::Value::Null, ¤t).unwrap(); - assert_eq!(a.rule_below, None, "null clears the override"); - } - - #[test] - fn stroke_behind_fill_is_saved_only_when_set() { - assert!(!CharProps::default().stroke_behind_fill); - assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({})); - let mut a = CharAttrs::default(); - a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap(); - let v = serde_json::to_value(&a).unwrap(); - assert_eq!(v, serde_json::json!({"strokeBehindFill": true})); - assert_eq!(serde_json::from_value::(v).unwrap(), a); - let mut p = CharProps::default(); - p.apply(&a); - assert!(p.stroke_behind_fill); - } - - #[test] - fn digits_map_to_their_script() { - assert_eq!(Digits::Default.map('7', "Arabic"), '7'); - assert_eq!(Digits::Hindi.map('3', ""), '\u{0663}'); - assert_eq!(Digits::Farsi.map('0', ""), '\u{06F0}'); - assert_eq!(Digits::Native.map('5', "Thai"), '\u{0E55}'); - assert_eq!(Digits::Native.map('5', "English: USA"), '5'); - assert_eq!(Digits::Hindi.map('x', ""), 'x'); - } - - #[test] - fn language_names_map_to_bcp47_tags() { - assert_eq!(language_tag("English: USA"), Some("en-US")); - assert_eq!(language_tag("English: Australian"), Some("en")); - assert_eq!(language_tag("Turkish"), Some("tr")); - assert_eq!(language_tag("Georgian"), Some("ka")); - assert_eq!(language_tag("Japanese"), Some("ja")); - assert_eq!(language_tag("Korean"), Some("ko")); - assert_eq!(language_tag("Chinese"), Some("zh")); - assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans")); - assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant")); - assert_eq!(language_tag("Arabic"), Some("ar")); - assert_eq!(language_tag("Persian"), Some("fa")); - assert_eq!(language_tag("Farsi"), Some("fa")); - assert_eq!(language_tag("Urdu"), Some("ur")); - assert_eq!(language_tag("Hebrew"), Some("he")); - assert_eq!(language_tag("[No Language]"), None); - assert_eq!(language_tag("Klingon"), None); - assert_eq!(language_tag(""), None); - } - - #[test] - fn chinese_japanese_and_korean_names_map_in_their_common_spellings() { - for (name, tag) in [ - ("Chinese: Simplified", Some("zh-Hans")), - ("Simplified Chinese", Some("zh-Hans")), - ("Chinese Simplified", Some("zh-Hans")), - ("Chinese (Simplified)", Some("zh-Hans")), - (" chinese: SIMPLIFIED ", Some("zh-Hans")), - ("zh_CN", Some("zh-Hans")), - ("zh-CN", Some("zh-Hans")), - ("zh-Hans", Some("zh-Hans")), - ("zh-Hans-CN", Some("zh-Hans")), - ("Chinese: Traditional", Some("zh-Hant")), - ("Traditional Chinese", Some("zh-Hant")), - ("Chinese Traditional", Some("zh-Hant")), - ("Chinese (Traditional)", Some("zh-Hant")), - ("zh_TW", Some("zh-Hant")), - ("zh-TW", Some("zh-Hant")), - ("zh_HK", Some("zh-Hant")), - ("zh-HK", Some("zh-Hant")), - ("zh-Hant", Some("zh-Hant")), - ("Chinese", Some("zh")), - ("zh", Some("zh")), - ("Japanese", Some("ja")), - ("ja", Some("ja")), - ("ja_JP", Some("ja-JP")), - ("ja-JP", Some("ja-JP")), - ("Korean", Some("ko")), - ("ko_KR", Some("ko-KR")), - ("ko-KR", Some("ko-KR")), - ("English: USA", Some("en-US")), - ("German: Swiss 2006 Reform", Some("de-CH")), - ("Norwegian: Bokmål", Some("nb")), - ("[No Language]", None), - ] { - assert_eq!(language_tag(name), tag, "{name}"); - } - } - - #[test] - fn locale_codes_map_to_their_tags() { - for (name, tag) in [ - // As InDesign-exported IDML writes them (AppliedLanguage). - ("$ID/de_DE_2006", Some("de-DE")), - ("$ID/nl_NL_2005", Some("nl-NL")), - ("$ID/English: USA", Some("en-US")), - ("$ID/English: UK", Some("en-GB")), - ("$ID/Spanish: Castilian", Some("es-ES")), - ("$ID/French", Some("fr")), - // As IDML import keeps them. - ("de_DE_2006", Some("de-DE")), - ("nl_NL_2005", Some("nl-NL")), - ("Spanish: Castilian", Some("es-ES")), - ("French", Some("fr")), - // Other locale codes. - ("de-DE", Some("de-DE")), - ("de_CH_2006", Some("de-CH")), - ("pt_BR", Some("pt-BR")), - ("en_us", Some("en-US")), - ("es_419", Some("es-419")), - ("fil_PH", Some("fil-PH")), - ("sv", Some("sv")), - ("ja", Some("ja")), - // Not codes. - ("de_DE_2006_x", None), - ("d_DE", None), - ("de_D", None), - ("de_DE_06", None), - ("1e_DE", None), - ("[No Language]", None), - ] { - assert_eq!(language_tag(name), tag, "{name}"); - } - assert_eq!(language_subtag("de-DE"), "de"); - assert_eq!(language_subtag("zh-Hant"), "zh"); - assert_eq!(language_subtag("ko"), "ko"); - } - - #[test] - fn merge_and_resolve() { - let mut a = CharAttrs { size: Some(10.0), ..Default::default() }; - a.merge(&CharAttrs { tracking: Some(20.0), size: Some(11.0), ..Default::default() }); - assert_eq!(a.size, Some(11.0)); - assert_eq!(a.count(), 2); - let mut p = CharProps::default(); - p.apply(&a); - assert_eq!(p.size, 11.0); - assert_eq!(p.tracking, 20.0); - assert_eq!(p.font_family, "Source Serif 4"); - } - - #[test] - fn tab_lists_are_checked() { - let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; - assert!(TabStop::checked_list(vec![stop(f64::NAN)]).is_err()); - assert!(TabStop::checked_list(vec![stop(f64::INFINITY)]).is_err()); - assert!(TabStop::checked_list((0..=MAX_TAB_STOPS).map(|i| stop(i as f64)).collect()).is_err()); - let list = TabStop::checked_list(vec![stop(50.0), TabStop { leader: ".".into(), ..stop(10.0) }, stop(50.001)]).unwrap(); - assert_eq!(list.iter().map(|t| t.position).collect::>(), [10.0, 50.001], "sorted, one stop per position"); - } - - /// A file's tab list is repaired, never refused; a list the commands accept comes through - /// unchanged. - #[test] - fn tab_lists_from_files_are_sanitized() { - let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; - let mut tabs: Vec = (0..500).rev().map(|i| stop(i as f64 * 10.0)).collect(); - tabs.push(stop(f64::NAN)); - tabs.push(stop(f64::NEG_INFINITY)); - tabs.push(TabStop { leader: "\u{7}0123456789".into(), align: TabAlign::Char, align_on: "\n,;".into(), ..stop(-3.0) }); - let list = TabStop::sanitized_list(tabs); - assert_eq!(list.len(), MAX_TAB_STOPS); - assert!(list.windows(2).all(|w| w[1].position - w[0].position >= SAME_TAB_POSITION), "sorted, one stop per position"); - let first = &list[0]; - assert_eq!((first.position, first.leader.as_str(), first.align_on.as_str()), (0.0, "01234567", ","), "the later stop at 0 wins"); - assert_eq!(list.last().map(|t| t.position), Some(990.0), "the first stops by position are kept"); - - let ok = TabStop::checked_list(vec![stop(72.0), TabStop { align: TabAlign::Char, ..stop(10.0) }]).unwrap(); - assert_eq!(TabStop::sanitized_list(ok.clone()), ok); - - // `null` is how JSON writes a non-finite position; that stop is dropped, the others kept. - let json = r#"{"tabs":[{"position":null},{"position":20000,"leader":"0123456789"},{"position":5}]}"#; - let a: ParaAttrs = serde_json::from_str(json).unwrap(); - let tabs = a.tabs.unwrap(); - assert_eq!(tabs.iter().map(|t| t.position).collect::>(), [5.0, MAX_TAB_POSITION]); - assert_eq!(tabs[1].leader, "01234567"); - let p: ParaProps = serde_json::from_str(json).unwrap(); - assert_eq!(p.tabs, tabs); - assert!(serde_json::from_str::("{}").is_err(), "a stop names its position"); - } - - #[test] - fn set_json_by_name() { - let mut a = ParaAttrs::default(); - a.set_json("spaceBefore", &serde_json::json!(6.0)).unwrap(); - a.set_json("align", &serde_json::json!("center")).unwrap(); - a.set_json("left_indent", &serde_json::json!(12)).unwrap(); - assert_eq!(a.space_before, Some(6.0)); - assert_eq!(a.align, Some(Align::Center)); - assert_eq!(a.left_indent, Some(12.0)); - assert!(a.set_json("nope", &serde_json::json!(1)).is_err()); - a.set_json("align", &serde_json::Value::Null).unwrap(); - assert_eq!(a.align, None); - } - - #[test] - fn delta_and_common() { - let a = CharProps::default(); - let mut b = a.clone(); - b.size = 18.0; - let d = a.delta(&b); - assert_eq!(d.count(), 1); - let c = CharProps::common([&a, &b]); - assert_eq!(c.size, None); - assert_eq!(c.font_family.as_deref(), Some("Source Serif 4")); - } - - #[test] - fn serde_skips_unset() { - let a = CharAttrs { size: Some(9.0), ..Default::default() }; - assert_eq!(serde_json::to_string(&a).unwrap(), r#"{"size":9.0}"#); - let p: ParaProps = serde_json::from_str(r#"{"align":"center"}"#).unwrap(); - assert_eq!(p.align, Align::Center); - assert_eq!(p.word_space_max, 1.33); - } - - #[test] - fn same_para_style_spacing_ignore_survives_serde() { - for v in [None, Some(None), Some(Some(0.0))] { - let a = ParaAttrs { same_para_style_spacing: v, ..Default::default() }; - let back: ParaAttrs = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); - assert_eq!(back.same_para_style_spacing, v); - } - } - - #[test] - fn numbering_styles() { - assert_eq!(NumberStyle::UpperRoman.format(14), "XIV"); - assert_eq!(NumberStyle::LowerRoman.format(9), "ix"); - assert_eq!(NumberStyle::UpperLetters.format(1), "A"); - assert_eq!(NumberStyle::UpperLetters.format(27), "AA"); - assert_eq!(NumberStyle::LowerLetters.format(28), "bb"); - assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03"); - assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007"); - assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012"); - // Repeating forms stay small for huge numbers. - assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string()); - assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string()); - assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string()); - } - - #[test] - fn kanji_numbers_are_written_digit_by_digit() { - for (n, s) in [ - (0, "〇"), - (1, "一"), - (5, "五"), - (10, "一〇"), - (11, "一一"), - (20, "二〇"), - (38, "三八"), - (99, "九九"), - (100, "一〇〇"), - (105, "一〇五"), - (1000, "一〇〇〇"), - (2026, "二〇二六"), - (u32::MAX, "四二九四九六七二九五"), - ] { - assert_eq!(NumberStyle::Kanji.format(n), s, "{n}"); - } - } - - #[test] - fn list_labels_expand_metacharacters() { - let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() }; - assert_eq!(p.number_label(2), "B.\t"); - let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() }; - assert_eq!(p.number_label(3), "Tabel 3\t"); - assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^"); - assert_eq!(expand_list_text("a\nb^p", "1"), "ab"); - assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t"); - } -} +mod tests; diff --git a/crates/doc/src/attrs/char_attrs.rs b/crates/doc/src/attrs/char_attrs.rs new file mode 100644 index 0000000000..b81dfbaf8c --- /dev/null +++ b/crates/doc/src/attrs/char_attrs.rs @@ -0,0 +1,175 @@ +use super::*; + +attr_set! { + /// Character attributes (Character panel / character styles / the character part of paragraph styles). + CharAttrs / CharProps { + font_family: String = "Source Serif 4".into(), + font_style: String = "Regular".into(), + /// Point size. + size: f64 = 12.0, + leading: Leading = Leading::Auto, + kerning: Kerning = Kerning::Metrics, + /// Tracking in 1/1000 em. + tracking: f64 = 0.0, + /// Explicit CJK aki, in em; None restores automatic spacing. + leading_aki: Option = None => "deserialize_explicit_value", + trailing_aki: Option = None => "deserialize_explicit_value", + /// Proportional character compression, 0..1. + tsume: f64 = 0.0, + /// Target em cells for the contiguous formatted group (0 disables jidori). + jidori: u32 = 0, + character_alignment: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, + leading_model: crate::cjk::LeadingModel = crate::cjk::LeadingModel::Roman, + /// The kenten mark's text: a custom mark, or the preset of `kenten_kind` (see + /// [`crate::cjk_settings::kenten_mark`]); empty is the sesame dot. + kenten_character: String = String::new(), + /// Horizontal / vertical scale, 1.0 = 100%. + h_scale: f64 = 1.0, + v_scale: f64 = 1.0, + baseline_shift: f64 = 0.0, + /// Skew (false italic), degrees. + skew: f64 = 0.0, + fill: String = "[Black]".into(), + fill_tint: f32 = 1.0, + stroke: String = "[None]".into(), + stroke_tint: f32 = 1.0, + stroke_weight: f64 = 1.0, + /// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the + /// letters at its full weight, so the letters keep their shape. Off, it straddles the outline. + stroke_behind_fill: bool = false, + capitalization: Capitalization = Capitalization::Normal, + position: Position = Position::Normal, + underline: bool = false, + strikethrough: bool = false, + /// Underline Options: weight and offset below the baseline (`None` = from the size), + /// colour (empty = the text colour) and tint. + underline_weight: Option = None => "deserialize_explicit_value", + underline_offset: Option = None => "deserialize_explicit_value", + underline_color: String = String::new(), + underline_tint: f32 = 1.0, + /// Strikethrough Options: offset above the baseline. + strikethrough_weight: Option = None => "deserialize_explicit_value", + strikethrough_offset: Option = None => "deserialize_explicit_value", + strikethrough_color: String = String::new(), + strikethrough_tint: f32 = 1.0, + glyph_form: String = String::new(), + ligatures: bool = true, + no_break: bool = false, + /// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line. + tate_chu_yoko: bool = false, + tate_chu_yoko_x_offset: f64 = 0.0, + tate_chu_yoko_y_offset: f64 = 0.0, + /// Ruby: the reading set small above the text (to its right in vertical text). Group ruby + /// sets it over the whole run; per-character ruby holds one reading per character, + /// separated by U+3000. Its other settings are the `ruby_*` fields below. + ruby: String = String::new(), + /// Kenten: an emphasis dot above each character. + kenten: bool = false, + /// Warichu: the run is set in smaller lines stacked inside one line height. + warichu: bool = false, + /// How many warichu lines (InDesign's minimum is 2). + warichu_lines: u32 = 2, + /// Warichu size as a percentage of the parent size. + warichu_size: f64 = 50.0, + /// Extra points between warichu baselines. 0 is automatic (one small em). + warichu_line_spacing: f64 = 0.0, + warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto, + /// Minimum characters on a warichu line before a break, and on the line after it. + warichu_chars_before_break: u32 = 1, + warichu_chars_after_break: u32 = 1, + // Kenten Settings / Kenten Color (`kenten` switches the marks on). + /// The mark; [`crate::cjk_settings::KentenKind::Custom`] draws `kenten_character`. + kenten_kind: crate::cjk_settings::KentenKind = crate::cjk_settings::KentenKind::SesameDot, + /// Font of a custom mark (empty = the text's font) and its style. + kenten_font: String = String::new(), + kenten_font_style: String = String::new(), + /// Mark size in points; `None` is half the text size. + kenten_size: Option = None, + /// Horizontal / vertical scale of the mark, 1.0 = 100%. + kenten_x_scale: f64 = 1.0, + kenten_y_scale: f64 = 1.0, + /// Points between the text's em box and the mark's. + kenten_distance: f64 = 0.0, + kenten_position: crate::cjk_settings::KentenPosition = crate::cjk_settings::KentenPosition::AboveRight, + kenten_alignment: crate::cjk_settings::KentenAlignment = crate::cjk_settings::KentenAlignment::Center, + /// How a custom mark was entered (IDML `KentenCharacterSet`), kept for interchange. + kenten_character_set: String = String::new(), + /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = + /// the text's). + kenten_fill: String = String::new(), + kenten_fill_tint: Option = None, + kenten_stroke: String = String::new(), + kenten_stroke_tint: Option = None, + kenten_stroke_weight: Option = None, + kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. + /// Compression in percent of the em (0 = off). + shatai_magnification: f64 = 0.0, + /// Degrees from the line direction, counter-clockwise. + shatai_angle: f64 = 45.0, + /// Turn the compressed character back so its edge along the line stays along the line. + shatai_adjust_rotation: bool = false, + /// Fit the advance to the compressed em box. + shatai_adjust_tsume: bool = true, + // Ruby settings (see [`crate::ruby`]). Defaults are the IDML text defaults. + ruby_type: crate::ruby::RubyType = crate::ruby::RubyType::Group, + /// How a ruby shorter than its parent is spread over it. + ruby_alignment: crate::ruby::RubyAlignment = crate::ruby::RubyAlignment::Jis, + ruby_position: crate::ruby::RubyPosition = crate::ruby::RubyPosition::AboveRight, + /// Points along the line, and away from the parent text across it. + ruby_x_offset: f64 = 0.0, + ruby_y_offset: f64 = 0.0, + /// Empty: the parent's font and style. + ruby_font: String = String::new(), + ruby_font_style: String = String::new(), + /// Points; `None` is half the parent size. + ruby_font_size: Option = None, + /// 1.0 = 100%. + ruby_x_scale: f64 = 1.0, + ruby_y_scale: f64 = 1.0, + /// Use the font's ruby glyphs (OpenType `ruby`). + ruby_open_type_pro: bool = true, + /// Tate-chu-yoko in vertical ruby for runs of up to this many digits (0 = off). + ruby_auto_tcy_digits: u32 = 0, + ruby_auto_tcy_include_roman: bool = false, + ruby_auto_tcy_auto_scale: bool = true, + /// IDML `RubyOverhang`, kept for round trips; `ruby_overhang_amount` sets the overhang. + ruby_overhang: bool = false, + ruby_overhang_amount: crate::ruby::RubyOverhang = crate::ruby::RubyOverhang::OneRuby, + ruby_parent_spacing: crate::ruby::RubyParentSpacing = crate::ruby::RubyParentSpacing::Aki121, + /// A longer ruby at a line's start or end is set flush with the line edge. + ruby_auto_align: bool = true, + /// A longer ruby is narrowed first, down to `ruby_scaling_min` (1.0 = 100%). + ruby_auto_scaling: bool = false, + ruby_scaling_min: f64 = 0.66, + /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. + ruby_fill: String = String::new(), + ruby_fill_tint: Option = None, + ruby_stroke: String = String::new(), + ruby_stroke_tint: Option = None, + ruby_stroke_weight: Option = None, + ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + /// Digits (World-Ready): how 0–9 are drawn. + digits: Digits = Digits::Default, + character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default, + /// Character-level permission, additionally gated by paragraph kashidas. + allow_kashidas: bool = true, + diacritic_position: crate::arabic::DiacriticPosition = crate::arabic::DiacriticPosition::Default, + /// Additional mark offsets, in thousandths of an em. Positive y is up. + diacritic_x_offset: f64 = 0.0, + diacritic_y_offset: f64 = 0.0, + /// IDML contextual-form request, separate from general OpenType features. + positional_form: String = String::new(), + language: String = "English: USA".into(), + /// Additional OpenType features, e.g. `["onum", "ss01"]`. + otf_features: Vec = Vec::new(), + /// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text). + conditions: Vec = Vec::new(), + /// Track Changes: text added or deleted while tracking (deleted text isn't laid out). + change: ChangeMark = ChangeMark::None, + /// XML: the element this text is tagged with (Tags panel, inline). + xml_tag: String = String::new(), + } +} diff --git a/crates/doc/src/attrs/enums.rs b/crates/doc/src/attrs/enums.rs new file mode 100644 index 0000000000..947deb66ad --- /dev/null +++ b/crates/doc/src/attrs/enums.rs @@ -0,0 +1,310 @@ +use super::*; + +// ---------- value types ---------- + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum Leading { + /// Auto leading (percentage of the largest size on the line, Justification → Auto Leading). + #[default] + Auto, + Points(f64), +} + +/// Paragraph direction. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextDirection { + #[default] + LeftToRight, + RightToLeft, +} + +/// Digits (World-Ready): the figures 0–9 are drawn as these. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Digits { + /// Contextual digits; composition selects the preceding strong script/language. + #[default] + Default, + /// European (0123). + Arabic, + /// Arabic-Indic (٠١٢٣). + Hindi, + /// Extended Arabic-Indic, as used for Persian and Urdu (۰۱۲۳). + Farsi, + /// Those of the text's language (Thai, Devanagari, Bengali, Arabic, Persian…); else as typed. + Native, +} + +impl Digits { + /// The digit `c` (0–9) is drawn as, for text in `language`. + pub fn map(self, c: char, language: &str) -> char { + let d = match c { + '0'..='9' => c as u32 - '0' as u32, + '\u{0660}'..='\u{0669}' => c as u32 - 0x0660, + '\u{06F0}'..='\u{06F9}' => c as u32 - 0x06F0, + _ => return c, + }; + let zero = match self { + Digits::Default => return c, + Digits::Arabic => 0x0030, + Digits::Hindi => 0x0660, + Digits::Farsi => 0x06F0, + Digits::Native => { + let l = language.to_ascii_lowercase(); + match () { + _ if l.starts_with("arabic") || l == "ar" || l.starts_with("ar-") => 0x0660, + _ if l.starts_with("persian") + || l.starts_with("farsi") + || l.starts_with("urdu") + || l == "fa" + || l.starts_with("fa-") + || l == "ur" + || l.starts_with("ur-") => + { + 0x06F0 + } + _ if l.starts_with("hindi") || l.starts_with("marathi") || l.starts_with("nepali") || l.starts_with("sanskrit") => 0x0966, + _ if l.starts_with("bengali") => 0x09E6, + _ if l.starts_with("gujarati") => 0x0AE6, + _ if l.starts_with("punjabi") => 0x0A66, + _ if l.starts_with("tamil") => 0x0BE6, + _ if l.starts_with("telugu") => 0x0C66, + _ if l.starts_with("kannada") => 0x0CE6, + _ if l.starts_with("malayalam") => 0x0D66, + _ if l.starts_with("thai") => 0x0E50, + _ if l.starts_with("lao") => 0x0ED0, + _ if l.starts_with("tibetan") => 0x0F20, + _ if l.starts_with("burmese") => 0x1040, + _ if l.starts_with("khmer") => 0x17E0, + _ => return c, + } + } + }; + char::from_u32(zero + d).unwrap_or(c) + } +} + +/// A tracked change on text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeMark { + #[default] + None, + Inserted, + Deleted, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum Kerning { + #[default] + Metrics, + Optical, + /// Zero ("0" in the kerning menu). + None, + /// Manual pair kerning in 1/1000 em (applies after this character). + Manual(f64), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Capitalization { + #[default] + Normal, + AllCaps, + SmallCaps, + OpenTypeAllSmallCaps, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Position { + #[default] + Normal, + Superscript, + Subscript, + OtSuperscript, + OtSubscript, + OtNumerator, + OtDenominator, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Align { + #[default] + Left, + Center, + Right, + LeftJustified, + CenterJustified, + RightJustified, + FullyJustified, + TowardsSpine, + AwayFromSpine, +} + +impl Align { + pub fn is_justified(self) -> bool { + matches!(self, Align::LeftJustified | Align::CenterJustified | Align::RightJustified | Align::FullyJustified) + } + pub const ALL: [Align; 9] = [ + Align::Left, + Align::Center, + Align::Right, + Align::LeftJustified, + Align::CenterJustified, + Align::RightJustified, + Align::FullyJustified, + Align::TowardsSpine, + Align::AwayFromSpine, + ]; + pub fn label(self) -> &'static str { + match self { + Align::Left => "Align left", + Align::Center => "Align center", + Align::Right => "Align right", + Align::LeftJustified => "Justify with last line aligned left", + Align::CenterJustified => "Justify with last line aligned center", + Align::RightJustified => "Justify with last line aligned right", + Align::FullyJustified => "Justify all lines", + Align::TowardsSpine => "Align towards spine", + Align::AwayFromSpine => "Align away from spine", + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Composer { + /// Total-fit (Knuth–Plass) over the whole paragraph. + #[default] + Paragraph, + /// Greedy, line by line. + SingleLine, + /// The Japanese paragraph composer: total fit, with the CJK leading model and character + /// alignment. + Japanese, + /// The Japanese single-line composer. + JapaneseSingleLine, +} + +impl Composer { + /// Breaks lines greedily, one at a time. + pub fn single_line(self) -> bool { + matches!(self, Composer::SingleLine | Composer::JapaneseSingleLine) + } + + /// A Japanese composer: lines follow the leading model ([`crate::cjk::LeadingModel`]) and + /// characters their [`crate::cjk::CharacterAlignment`]. The others set lines baseline to + /// baseline with every character on the Roman baseline. + pub fn japanese(self) -> bool { + matches!(self, Composer::Japanese | Composer::JapaneseSingleLine) + } +} + +/// What a nested style runs to (Drop Caps and Nested Styles). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "chars")] +pub enum NestedUntil { + Sentences, + #[default] + Words, + Characters, + Letters, + Digits, + Tab, + ForcedLineBreak, + EmSpace, + EnSpace, + /// The paragraph's drop cap characters. + Dropcap, + /// Any of these characters. + Chars(String), +} + +/// A character style applied from the start of the paragraph through (or up to) the `count`-th +/// delimiter; nested styles follow each other. +/// A nested line style: `style` for the next `lines` lines. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NestedLineStyle { + pub style: String, + pub lines: u32, +} + +impl Default for NestedLineStyle { + fn default() -> Self { + NestedLineStyle { style: String::new(), lines: 1 } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NestedStyle { + pub style: String, + /// Through (include the delimiter) or up to (stop before it). + pub through: bool, + pub count: u32, + pub until: NestedUntil, +} + +impl Default for NestedStyle { + fn default() -> Self { + NestedStyle { style: String::new(), through: true, count: 1, until: NestedUntil::Words } + } +} + +impl NestedStyle { + /// The drop cap's character style as a nested style: `style` through 1 drop cap. + pub fn drop_cap(style: &str) -> Self { + NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap } + } + + /// Is this the form a drop cap's character style takes at the start of a nested style list? + pub fn is_drop_cap(&self) -> bool { + self.until == NestedUntil::Dropcap && self.through && self.count == 1 + } +} + +/// A character style applied to every match of a regular expression in the paragraph. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GrepStyle { + pub style: String, + pub pattern: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridAlign { + #[default] + None, + AllLines, + FirstLineOnly, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartParagraph { + #[default] + Anywhere, + NextColumn, + NextFrame, + NextPage, + NextOddPage, + NextEvenPage, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "columns")] +pub enum SpanColumns { + #[default] + Single, + /// Span `n` columns (0 = all). + Span(u32), + /// Split into `n` sub-columns. + Split(u32), +} diff --git a/crates/doc/src/attrs/language.rs b/crates/doc/src/attrs/language.rs new file mode 100644 index 0000000000..35ac044a15 --- /dev/null +++ b/crates/doc/src/attrs/language.rs @@ -0,0 +1,134 @@ +/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings? +/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps +/// text, never for a sentence's, a title's or an index heading's first letter. +pub fn capitalizes(c: char) -> bool { + !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') +} + +/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: +/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`) +/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking, +/// fallback fonts). A variant we don't know falls back to its language ("English: Australian" → +/// `en`); an unknown language (or "[No Language]") has none. Case, punctuation and IDML's `$ID/` +/// don't matter. Locale codes (`ll`, `ll_CC`, `ll-CC`, with a reform year after them as in +/// `de_DE_2006`) are their tag (`de-DE`), and Chinese also goes by its other common spellings +/// ("Simplified Chinese", "Chinese (Traditional)", "zh_CN", "zh-Hant"). +pub fn language_tag(language: &str) -> Option<&'static str> { + let language = language.trim(); + let language = language.strip_prefix("$ID/").unwrap_or(language); + let l = language.to_lowercase(); + let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + let first = words.first().copied().unwrap_or(""); + let has = |any: &[&str]| words.iter().any(|w| any.contains(w)); + if first == "zh" || has(&["chinese"]) { + // The script decides; then the name; then the region. + return Some(if has(&["hans"]) { + "zh-Hans" + } else if has(&["hant"]) { + "zh-Hant" + } else if has(&["simplified", "cn", "sg"]) { + "zh-Hans" + } else if has(&["traditional", "tw", "hk", "mo"]) { + "zh-Hant" + } else { + "zh" + }); + } + if let Some(tag) = locale_code(&words) { + return intern_tag(tag); + } + let variant = match words.join(" ").as_str() { + "english usa" => Some("en-US"), + "english uk" => Some("en-GB"), + "english canadian" => Some("en-CA"), + "german swiss 2006 reform" | "german swiss" => Some("de-CH"), + "french swiss" => Some("fr-CH"), + "italian swiss" => Some("it-CH"), + "spanish castilian" => Some("es-ES"), + "french canadian" => Some("fr-CA"), + "portuguese brazilian" => Some("pt-BR"), + "norwegian bokmål" | "norwegian bokmal" => Some("nb"), + "norwegian nynorsk" => Some("nn"), + _ => None, + }; + if variant.is_some() { + return variant; + } + Some(match first { + "english" => "en", + "german" => "de", + "french" => "fr", + "spanish" => "es", + "catalan" => "ca", + "italian" => "it", + "portuguese" => "pt", + "dutch" => "nl", + "danish" => "da", + "swedish" => "sv", + "norwegian" => "no", + "finnish" => "fi", + "polish" => "pl", + "czech" => "cs", + "slovak" => "sk", + "hungarian" => "hu", + "romanian" => "ro", + "croatian" => "hr", + "lithuanian" => "lt", + "greek" => "el", + "russian" => "ru", + "ukrainian" => "uk", + "bulgarian" => "bg", + "serbian" => "sr", + "macedonian" => "mk", + "turkish" => "tr", + "japanese" => "ja", + "korean" => "ko", + "arabic" => "ar", + "persian" | "farsi" => "fa", + "urdu" => "ur", + "hebrew" => "he", + "georgian" => "ka", + _ => return None, + }) +} + +/// The tag of a locale code split into lowercase `words`: a language (2 or 3 letters), then +/// optionally a region (2 letters or 3 digits) and a reform year (4 digits) → `ll` or `ll-CC`. +fn locale_code(words: &[&str]) -> Option { + let letters = |w: &str, n: std::ops::RangeInclusive| n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()); + let digits = |w: &str, n: usize| w.len() == n && w.bytes().all(|b| b.is_ascii_digit()); + match *words { + [lang] if letters(lang, 2..=3) => Some(lang.to_string()), + [lang, region] | [lang, region, _] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) => { + if let [_, _, year] = *words + && !digits(year, 4) + { + return None; + } + Some(format!("{lang}-{}", region.to_ascii_uppercase())) + } + _ => None, + } +} + +/// `tag` as a `&'static str`: each distinct tag is kept once for the process. Language names come +/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`). +fn intern_tag(tag: String) -> Option<&'static str> { + const MAX_TAGS: usize = 512; + static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(t) = tags.iter().find(|t| **t == tag) { + return Some(t); + } + if tags.len() >= MAX_TAGS { + return None; + } + let t: &'static str = Box::leak(tag.into_boxed_str()); + tags.push(t); + Some(t) +} + +/// The language subtag of a BCP 47 tag (`de-DE` → `de`, `zh-Hant` → `zh`). +pub fn language_subtag(tag: &str) -> &str { + tag.split('-').next().unwrap_or(tag) +} diff --git a/crates/doc/src/attrs/para_attrs.rs b/crates/doc/src/attrs/para_attrs.rs new file mode 100644 index 0000000000..bf4bb3bbee --- /dev/null +++ b/crates/doc/src/attrs/para_attrs.rs @@ -0,0 +1,250 @@ +use super::*; + +attr_set! { + /// Paragraph attributes (Paragraph panel / paragraph styles). + ParaAttrs / ParaProps { + align: Align = Align::Left, + /// Paragraph Direction (World-Ready): the base direction lines are ordered in. + direction: TextDirection = TextDirection::LeftToRight, + left_indent: f64 = 0.0, + right_indent: f64 = 0.0, + first_line_indent: f64 = 0.0, + last_line_indent: f64 = 0.0, + space_before: f64 = 0.0, + space_after: f64 = 0.0, + /// Space between consecutive paragraphs of the same style; replaces space after/before there (None = ignore). + same_para_style_spacing: Option = None => "deserialize_explicit_value", + /// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never + /// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the + /// character style `drop_cap_style` ("" or `[None]`: the text's own formatting). + drop_cap_lines: u32 = 0, + drop_cap_chars: u32 = 0, + drop_cap_style: String = String::new(), + /// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side + /// bearing is taken off), and the lines beside it start where its advance ends. + drop_cap_align_left: bool = true, + /// Scale for Descenders (kept and round-tripped; doesn't change the layout). + drop_cap_scale_descenders: bool = false, + /// Nested styles, in order from the paragraph start. + nested_styles: Vec = Vec::new(), + /// GREP styles: a character style for every match of a pattern (applied after nested). + grep_styles: Vec = Vec::new(), + /// Nested line styles: a character style for the paragraph's first lines, in order. + nested_line_styles: Vec = Vec::new(), + grid_align: GridAlign = GridAlign::None, + composer: Composer = Composer::Paragraph, + // Hyphenation + hyphenate: bool = true, + hyph_min_word: u32 = 5, + hyph_after_first: u32 = 2, + hyph_before_last: u32 = 2, + /// Max consecutive hyphenated lines (0 = unlimited). + hyph_limit: u32 = 3, + hyph_zone: f64 = 36.0, + hyph_capitalized: bool = true, + hyph_last_word: bool = true, + hyph_across_column: bool = true, + /// Better spacing (0) ↔ fewer hyphens (1). + hyph_weight: f64 = 0.5, + // Justification (percent as fractions) + word_space_min: f64 = 0.8, + word_space_desired: f64 = 1.0, + word_space_max: f64 = 1.33, + letter_space_min: f64 = 0.0, + letter_space_desired: f64 = 0.0, + letter_space_max: f64 = 0.0, + glyph_scale_min: f64 = 1.0, + glyph_scale_desired: f64 = 1.0, + glyph_scale_max: f64 = 1.0, + auto_leading: f64 = 1.2, + single_word_justify: Align = Align::FullyJustified, + /// Insert Kashidas (World-Ready): justified Arabic lines stretch at joins before spaces. + kashidas: bool = true, + /// Preserved vendor policy; non-default policies require a dedicated composer. + arabic_justification: String = String::new(), + paragraph_kashida_width: Option = None => "deserialize_explicit_value", + /// Korean text breaks between any two syllables (character-based breaking); otherwise + /// it breaks at spaces, word by word. + korean_char_breaks: bool = false, + // Keeps + keep_with_next: u32 = 0, + keep_lines_together: bool = false, + /// All lines in paragraph (true) or at start/end of paragraph (false). + keep_all_lines: bool = false, + keep_first: u32 = 2, + keep_last: u32 = 2, + start_paragraph: StartParagraph = StartParagraph::Anywhere, + span_columns: SpanColumns = SpanColumns::Single, + /// Split Columns: the space between sub-columns and the inset from the column's edges. + split_inside_gutter: f64 = 6.0, + split_outside_gutter: f64 = 0.0, + rule_above: Rule = Rule::default(), + rule_below: Rule = Rule::default(), + #[serde(deserialize_with = "sanitized_tabs")] + tabs: Vec = Vec::new(), + list_type: ListType = ListType::None, + /// Named list (Define Lists) the numbers belong to ("" = the story's own list). + list_name: String = String::new(), + /// Start At: restart the numbering at this number. + start_at: Option = None => "deserialize_explicit_value", + bullet_char: String = "\u{2022}".into(), + number_style: NumberStyle = NumberStyle::Arabic, + /// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in + /// [`expand_list_text`]. + number_expression: String = "^#.^t".into(), + /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). + list_separator: String = "\t".into(), + /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away + /// again with the list (an indent the paragraph had before stays). + list_hang: bool = false, + balance_ragged: bool = false, + /// Hang punctuation outside the measure in this paragraph, at each character's own size + /// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on + /// overrides it with the story's size). + optical_margin: bool = false, + /// Ignore Optical Margin: no hang in this paragraph, whatever the story or + /// `optical_margin` say. + ignore_optical_margin: bool = false, + /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. + kinsoku: Option = None => "deserialize_explicit_value", + /// Preserved spacing-table reference; compatibility audit reports unsupported composition. + mojikumi: String = String::new(), + /// Preserved priority policy; the current breaker does not implement these priorities. + kinsoku_type: String = String::new(), + kinsoku_hang: crate::cjk::KinsokuHang = crate::cjk::KinsokuHang::None, + bunri_kinshi: bool = false, + rensuuji: bool = true, + treat_ideographic_space_as_space: bool = false, + // Auto Tate-chu-yoko, Japanese Composition and Grid Settings. + /// Auto tate-chu-yoko: in vertical text, runs of up to this many half-width digits are set + /// across one em (0 = off). + auto_tcy: u32 = 0, + /// Auto tate-chu-yoko also takes runs of half-width roman letters. + auto_tcy_include_roman: bool = false, + /// Rotate Roman Characters in Vertical Text (縦組み中の欧文回転): half-width characters + /// stand upright one by one instead of lying along the line. + rotate_roman: bool = false, + /// Roman word break (欧文泣き別れ): roman words may break between any two letters, + /// without a hyphen. + roman_word_break: bool = false, + /// The point of a line that sits on the grid while `grid_align` is on. + grid_reference: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, + /// Gyoudori (行取り): grid lines each line takes (0 = as many as its leading needs). + grid_gyoudori: u32 = 0, + /// Paragraph gyoudori: the paragraph as a whole, not each line, takes `grid_gyoudori` lines. + paragraph_gyoudori: bool = false, + /// Paragraph shading, and how far it reaches past the text: top, left, bottom, right. + shading_on: bool = false, + shading_color: String = "[Black]".into(), + shading_tint: f32 = 0.2, + shading_offsets: [f64; 4] = [0.0; 4], + /// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the + /// text (top, left, bottom, right). + border_on: bool = false, + border_weights: [f64; 4] = [1.0; 4], + border_color: String = "[Black]".into(), + border_tint: f32 = 1.0, + border_offsets: [f64; 4] = [0.0; 4], + /// The border's stroke type (a document stroke style by name, or a built-in type), cap and + /// join. + border_type: crate::item::StrokeType = crate::item::StrokeType::Solid, + border_cap: crate::item::Cap = crate::item::Cap::Butt, + border_join: crate::item::Join = crate::item::Join::Miter, + /// Corner shape and size: top left, top right, bottom right, bottom left. + border_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], + border_width: BoxWidth = BoxWidth::Column, + border_top: BoxTop = BoxTop::Ascent, + border_bottom: BoxBottom = BoxBottom::Descent, + /// A paragraph split across frames or columns gets a closed box in each part (otherwise the + /// box is open where it splits). + border_display_if_splits: bool = false, + /// Consecutive paragraphs with the same border and shading share one box. + border_merge: bool = true, + /// The colour between the dashes, dots or stripes of a border that isn't solid. + border_gap_color: String = "[None]".into(), + border_gap_tint: f32 = 1.0, + /// Shading corners: top left, top right, bottom right, bottom left. + shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], + shading_width: BoxWidth = BoxWidth::Column, + shading_top: BoxTop = BoxTop::Ascent, + shading_bottom: BoxBottom = BoxBottom::Descent, + /// The shading stops at the edges of the text frame. + shading_clip: bool = false, + /// The shading shows on screen only: not printed or exported. + shading_nonprinting: bool = false, + /// Least space between a spanning or split paragraph and the column text above and below it. + span_space_before: f64 = 0.0, + span_space_after: f64 = 0.0, + /// List level (1–9): each level numbers on its own, and `^1`…`^9` in a number's text are the + /// current numbers of those levels. + list_level: u32 = 1, + /// Character styles of the bullet and of the number. + bullet_char_style: String = crate::story::NO_CHAR_STYLE.into(), + number_char_style: String = crate::story::NO_CHAR_STYLE.into(), + bullet_align: ListAlign = ListAlign::Left, + number_align: ListAlign = ListAlign::Left, + /// A tab stop for the text after the bullet or number, from the column's left edge. + list_tab: Option = None, + /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise + /// that level) starts this level's numbering again. + restart_numbers: bool = true, + restart_after_level: u32 = 0, + /// The bullet's font family and style ("" = the paragraph's). + bullet_font: String = String::new(), + bullet_font_style: String = String::new(), + } +} + +impl ParaAttrs { + /// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the + /// rule fields to change; the others keep their values in `current` (the resolved attributes + /// of the paragraph or style being edited). A `tabs` list is checked and sorted as + /// [`TabStop::checked_list`] does. + pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> { + let rule = match key.chars().filter(|c| *c != '_').collect::().to_ascii_lowercase().as_str() { + "ruleabove" => ¤t.rule_above, + "rulebelow" => ¤t.rule_below, + "tabs" if !value.is_null() => { + // Count before deserializing: the list may be arbitrarily long. + if value.as_array().is_some_and(|a| a.len() > MAX_TAB_STOPS) { + return Err(format!("{key}: a paragraph holds at most {MAX_TAB_STOPS} tab stops")); + } + let tabs: Vec = serde_json::from_value(value.clone()).map_err(|e| format!("{key}: {e}"))?; + self.tabs = Some(TabStop::checked_list(tabs).map_err(|e| format!("{key}: {e}"))?); + return Ok(()); + } + _ => return self.set_json(key, value), + }; + let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; + let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; + let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + for (k, v) in patch { + if !fields.contains_key(k) { + return Err(format!("{key}: unknown rule field `{k}`")); + } + fields.insert(k.clone(), v.clone()); + } + self.set_json(key, &merged) + } +} + +impl ParaProps { + /// The label of a numbered paragraph with number `n`. + pub fn number_label(&self, n: u32) -> String { + expand_list_text(&self.number_expression, &self.number_style.format(n)) + } + + /// The label of a numbered paragraph that `list` has just numbered ([`ListCounter::advance`]). + pub fn number_label_in(&self, list: &ListCounter) -> String { + let level = list_level_index(self.list_level); + let number = list.labels.get(level).map_or("", String::as_str); + expand_list_levels(&self.number_expression, number, &list.labels) + } + + /// The label of a bulleted paragraph. + pub fn bullet_label(&self) -> String { + let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect(); + s.push_str(&expand_list_text(&self.list_separator, "")); + s + } +} diff --git a/crates/doc/src/attrs/rules_lists.rs b/crates/doc/src/attrs/rules_lists.rs new file mode 100644 index 0000000000..dde7643026 --- /dev/null +++ b/crates/doc/src/attrs/rules_lists.rs @@ -0,0 +1,285 @@ +use super::*; + +/// The colour of a paragraph rule that follows the paragraph's text colour. +pub const TEXT_COLOR: &str = "Text Color"; + +/// A paragraph rule (Rule Above / Rule Below). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Rule { + pub on: bool, + pub weight: f64, + /// A swatch, or [`TEXT_COLOR`]. + pub color: String, + pub tint: f32, + /// Width: column (true) or text (false). + pub column_width: bool, + pub offset: f64, + pub left_indent: f64, + pub right_indent: f64, +} + +impl Default for Rule { + fn default() -> Self { + Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + } +} + +/// How wide a paragraph border or shading box is: the column, or the text's longest line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxWidth { + #[default] + Column, + Text, +} + +/// Where a paragraph border or shading box starts above the first line (before its top offset). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxTop { + /// The tallest ascent on the line. + #[default] + Ascent, + Baseline, + /// The top of the em box. + EmBox, + /// The top of the line's leading. + Leading, +} + +/// Where a paragraph border or shading box ends below the last line (before its bottom offset). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxBottom { + /// The deepest descent on the line. + #[default] + Descent, + Baseline, + /// The bottom of the em box. + EmBox, +} + +/// Where a bullet or number sits against the first line's start (the left and first-line +/// indents): it starts there, is centred on it, or ends there. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListAlign { + #[default] + Left, + Center, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListType { + #[default] + None, + Bullets, + Numbers, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NumberStyle { + #[default] + Arabic, + UpperRoman, + LowerRoman, + UpperLetters, + LowerLetters, + /// 01, 02, … + ArabicLeadingZero, + /// 001, 002, … + ArabicThreeDigits, + /// 0001, 0002, … + ArabicFourDigits, + /// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes. + Symbols, + /// 一, 二, 三, …: Chinese numerals written digit by digit, so 10 is 一〇 and 38 is 三八. + Kanji, +} + +/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman +/// numerals, letters, symbols), so a hostile start number can't build a huge label. +const MAX_REPEATED_FORM: u32 = 3999; + +impl NumberStyle { + pub fn format(self, n: u32) -> String { + if n > MAX_REPEATED_FORM + && matches!( + self, + NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols + ) + { + return n.to_string(); + } + match self { + NumberStyle::Arabic => n.to_string(), + NumberStyle::ArabicLeadingZero => format!("{n:02}"), + NumberStyle::ArabicThreeDigits => format!("{n:03}"), + NumberStyle::ArabicFourDigits => format!("{n:04}"), + NumberStyle::UpperRoman => roman(n), + NumberStyle::LowerRoman => roman(n).to_lowercase(), + NumberStyle::UpperLetters => letters(n), + NumberStyle::LowerLetters => letters(n).to_lowercase(), + NumberStyle::Symbols => { + const SYM: [char; 6] = ['*', '†', '‡', '§', '¶', '‖']; + let n = n.max(1) as usize - 1; + std::iter::repeat_n(SYM[n % SYM.len()], n / SYM.len() + 1).collect() + } + NumberStyle::Kanji => kanji_digits(n), + } + } +} + +/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …), +/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no +/// meaning in a label are dropped; a label never holds a paragraph break. +pub fn expand_list_text(s: &str, number: &str) -> String { + expand_list_levels(s, number, &[]) +} + +/// [`expand_list_text`] with `levels[k]` in for `^1`…`^9` (level k + 1's current number; a level +/// past the end is dropped). +pub fn expand_list_levels(s: &str, number: &str, levels: &[String]) -> String { + let mut out = String::with_capacity(s.len() + number.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + if c != '\n' && c != '\r' { + out.push(c); + } + continue; + } + let Some(m) = it.next() else { + out.push('^'); + break; + }; + let r = match m { + '#' => { + out.push_str(number); + continue; + } + d @ '1'..='9' => { + let k = d as usize - '1' as usize; + if let Some(l) = levels.get(k) { + out.push_str(l); + } + continue; + } + '^' => '^', + 't' => '\t', + 'y' => crate::story::RIGHT_INDENT_TAB, + 'i' => crate::story::INDENT_HERE, + 'n' => crate::story::FORCED_LINE_BREAK, + 'm' => '\u{2003}', + '>' => '\u{2002}', + 'f' => '\u{2001}', + '|' => '\u{200A}', + '<' => '\u{2009}', + '/' => '\u{2007}', + '.' => '\u{2008}', + 's' => '\u{A0}', + 'S' => '\u{202F}', + '_' => '\u{2014}', + '=' => '\u{2013}', + '~' => '\u{2011}', + '{' => '\u{201C}', + '}' => '\u{201D}', + '[' => '\u{2018}', + ']' => '\u{2019}', + '"' => '"', + '\'' => '\'', + _ => continue, + }; + out.push(r); + } + out +} + +fn kanji_digits(n: u32) -> String { + const DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| DIGITS.get(d as usize)).copied().unwrap_or(c)).collect() +} + +fn roman(mut n: u32) -> String { + if n == 0 { + return "0".into(); + } + let t = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + let mut s = String::new(); + for (v, r) in t { + while n >= v { + s.push_str(r); + n -= v; + } + } + s +} + +/// A, B, …, Z, AA, BB, … (InDesign's letter numbering repeats the letter). +fn letters(n: u32) -> String { + if n == 0 { + return String::new(); + } + let i = (n - 1) % 26; + let reps = (n - 1) / 26 + 1; + std::iter::repeat_n(char::from(b'A' + i as u8), reps as usize).collect() +} + +/// Levels a list can have. +pub const LIST_LEVELS: usize = 9; + +/// Index (0-based) of list level `level` (1-based, clamped to 1–[`LIST_LEVELS`]). +pub(super) fn list_level_index(level: u32) -> usize { + (level.clamp(1, LIST_LEVELS as u32) - 1) as usize +} + +/// The numbering of one list as it goes: each level's last number and its text, and when each +/// level last numbered a paragraph (for Restart Numbers at This Level After). +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct ListCounter { + numbers: [u32; LIST_LEVELS], + pub(super) labels: [String; LIST_LEVELS], + seen: [u64; LIST_LEVELS], + tick: u64, +} + +impl ListCounter { + /// Number the next paragraph of `pp` (a numbered paragraph with text) and return its number. + pub fn advance(&mut self, pp: &ParaProps) -> u32 { + let l = list_level_index(pp.list_level); + self.tick = self.tick.saturating_add(1); + let since = self.seen[l]; + let restart = pp.restart_numbers + && match pp.restart_after_level { + 0 => self.seen[..l].iter().any(|&t| t > since), + m => (m as usize) <= l && self.seen.get(m as usize - 1).is_some_and(|&t| t > since), + }; + let n = match pp.start_at { + Some(s) => s.max(1), + None if restart => 1, + None => self.numbers[l].saturating_add(1), + }; + self.numbers[l] = n; + self.labels[l] = pp.number_style.format(n); + self.seen[l] = self.tick; + n + } +} diff --git a/crates/doc/src/attrs/tabs.rs b/crates/doc/src/attrs/tabs.rs new file mode 100644 index 0000000000..6962bcc737 --- /dev/null +++ b/crates/doc/src/attrs/tabs.rs @@ -0,0 +1,181 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TabAlign { + #[default] + Left, + Center, + Right, + /// Align on a character (default `.`). + Char, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabStop { + /// A file that held a non-finite position (written as `null`) reads it back as NaN, which + /// [`TabStop::sanitized_list`] drops. + #[serde(deserialize_with = "position_or_nan")] + pub position: f64, + #[serde(default)] + pub align: TabAlign, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub leader: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub align_on: String, +} + +/// Most stops a paragraph holds. +pub const MAX_TAB_STOPS: usize = 100; + +/// Furthest stop position: 216 in, the largest page. +pub const MAX_TAB_POSITION: f64 = 15_552.0; + +/// Longest leader, in characters. +pub const MAX_TAB_LEADER: usize = 8; + +/// Stops closer than this share a position. +pub const SAME_TAB_POSITION: f64 = 0.01; + +/// Smallest type size the engine accepts, in points. +pub const MIN_TYPE_SIZE: f64 = 0.1; + +/// Largest type size the engine accepts, in points, as InDesign's own field allows. +pub const MAX_TYPE_SIZE: f64 = 1296.0; + +/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab +/// stop, which is the widest page. +pub const MAX_MEASURE: f64 = MAX_TAB_POSITION; + +/// A type size read from a file, or `None` when it is not a size the engine can mean. +/// +/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer +/// had its own spelling of this test, or only checked that the value was finite -- so tagged text +/// took `` and set a 1e300 pt size on the text (#1593). A size outside the range is +/// dropped rather than clamped, so the text keeps the size its style gives it. +pub fn imported_type_size(pt: f64) -> Option { + (pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt) +} + +/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the +/// value is not finite. +/// +/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything" +/// still indents to the widest page the engine has, where dropping the value would quietly +/// restore the style's own indent and hide what the file asked for (#1593). +pub fn imported_measure(pt: f64) -> Option { + pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE)) +} + +impl TabStop { + /// A finite position clamped to `0..=MAX_TAB_POSITION`. + pub fn clamp_position(v: f64) -> Result { + if v.is_finite() { Ok(v.clamp(0.0, MAX_TAB_POSITION)) } else { Err("position must be a finite number".into()) } + } + + /// A leader: up to [`MAX_TAB_LEADER`] characters, no control characters. + pub fn check_leader(v: &str) -> Result<(), String> { + if v.chars().count() > MAX_TAB_LEADER || v.chars().any(char::is_control) { + return Err(format!("`leader`: at most {MAX_TAB_LEADER} printable characters")); + } + Ok(()) + } + + /// An align-on character: one printable character or none (a decimal point). + pub fn check_align_on(v: &str) -> Result<(), String> { + if v.chars().count() > 1 || v.chars().any(char::is_control) { + return Err("`alignOn`: one printable character".into()); + } + Ok(()) + } + + /// A tab-stop list as the Tabs commands keep it: at most [`MAX_TAB_STOPS`] stops, positions + /// clamped, leaders and align-on characters checked, a char stop aligning on `.` unless it + /// names a character, sorted by position, and one stop per position (the later one wins). + pub fn checked_list(mut tabs: Vec) -> Result, String> { + if tabs.len() > MAX_TAB_STOPS { + return Err(format!("a paragraph holds at most {MAX_TAB_STOPS} tab stops")); + } + for t in &mut tabs { + t.position = Self::clamp_position(t.position)?; + Self::check_leader(&t.leader)?; + Self::check_align_on(&t.align_on)?; + } + Ok(Self::normalized(tabs)) + } + + /// A tab-stop list read from a file, repaired to what [`checked_list`](Self::checked_list) + /// accepts: stops with a non-finite position dropped, positions clamped, control characters + /// removed from leaders and align-on characters and both cut to their limits, sorted, one stop + /// per position (the later one wins), and the first [`MAX_TAB_STOPS`] by position kept. + pub fn sanitized_list(tabs: Vec) -> Vec { + let tabs = tabs + .into_iter() + .filter_map(|mut t| { + t.position = Self::clamp_position(t.position).ok()?; + if Self::check_leader(&t.leader).is_err() { + t.leader = t.leader.chars().filter(|c| !c.is_control()).take(MAX_TAB_LEADER).collect(); + } + if Self::check_align_on(&t.align_on).is_err() { + t.align_on = t.align_on.chars().filter(|c| !c.is_control()).take(1).collect(); + } + Some(t) + }) + .collect(); + let mut out = Self::normalized(tabs); + out.truncate(MAX_TAB_STOPS); + out + } + + /// Finite, clamped stops sorted by position, one per position: of stops closer than + /// [`SAME_TAB_POSITION`], the later one in `tabs` wins. A char stop with no character aligns + /// on `.`. + fn normalized(tabs: Vec) -> Vec { + let mut stops: Vec<(usize, TabStop)> = tabs.into_iter().enumerate().collect(); + stops.sort_by(|a, b| a.1.position.total_cmp(&b.1.position)); + let mut out: Vec<(usize, TabStop)> = Vec::with_capacity(stops.len()); + for (i, mut t) in stops { + if t.align == TabAlign::Char && t.align_on.is_empty() { + t.align_on = ".".into(); + } + match out.last_mut() { + Some(last) if t.position - last.1.position < SAME_TAB_POSITION => { + if i > last.0 { + *last = (i, t); + } + } + _ => out.push((i, t)), + } + } + out.into_iter().map(|(_, t)| t).collect() + } +} + +/// A stop's position; `null` (how JSON writes a non-finite number) reads as NaN. +fn position_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) +} + +/// A paragraph's tab-stop list in either attribute form, as read from a file. +pub(super) trait FileTabs: Sized { + fn sanitized(self) -> Self; +} + +impl FileTabs for Vec { + fn sanitized(self) -> Self { + TabStop::sanitized_list(self) + } +} + +impl FileTabs for Option> { + fn sanitized(self) -> Self { + self.map(TabStop::sanitized_list) + } +} + +/// Deserializes a `tabs` field through [`TabStop::sanitized_list`]: a saved document's tab lists +/// get the limits the Tabs commands keep. +pub(super) fn sanitized_tabs<'de, D: serde::Deserializer<'de>, T: Deserialize<'de> + FileTabs>(d: D) -> Result { + T::deserialize(d).map(FileTabs::sanitized) +} diff --git a/crates/doc/src/attrs/tests.rs b/crates/doc/src/attrs/tests.rs new file mode 100644 index 0000000000..068fbaee8e --- /dev/null +++ b/crates/doc/src/attrs/tests.rs @@ -0,0 +1,316 @@ +use super::*; + +/// #777: an unknown attribute names the nearest one, or lists them all. +#[test] +fn unknown_attributes_suggest_the_right_name() { + let mut c = CharAttrs::default(); + assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?"); + assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?"); + let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err(); + assert!(all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"), "{all}"); + assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok()); + let mut p = ParaAttrs::default(); + assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?"); + assert_eq!(camel("first_line_indent"), "firstLineIndent"); +} + +#[test] +fn levels_number_on_their_own_and_restart_after_a_higher_level() { + let at = |level: u32| ParaProps { list_type: ListType::Numbers, list_level: level, number_expression: "^1.^#".into(), ..ParaProps::default() }; + let mut c = ListCounter::default(); + let mut labels = vec![]; + for level in [1, 2, 2, 1, 2] { + let pp = at(level); + c.advance(&pp); + labels.push(pp.number_label_in(&c)); + } + assert_eq!(labels, ["1.1", "1.1", "1.2", "2.2", "2.1"]); + // Without the restart, level 2 carries on. + let mut c = ListCounter::default(); + let keep = |level: u32| ParaProps { restart_numbers: false, ..at(level) }; + let n: Vec = [1, 2, 1, 2].into_iter().map(|l| c.advance(&keep(l))).collect(); + assert_eq!(n, [1, 1, 2, 2]); +} + +#[test] +fn a_partial_rule_keeps_the_other_fields() { + let current = ParaProps { + rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() }, + ..ParaProps::default() + }; + let mut a = ParaAttrs::default(); + a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), ¤t).unwrap(); + assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() })); + assert_eq!(a.rule_above, None); + assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), ¤t).is_err(), "unknown field"); + a.set_json_over("ruleBelow", &serde_json::Value::Null, ¤t).unwrap(); + assert_eq!(a.rule_below, None, "null clears the override"); +} + +#[test] +fn stroke_behind_fill_is_saved_only_when_set() { + assert!(!CharProps::default().stroke_behind_fill); + assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({})); + let mut a = CharAttrs::default(); + a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap(); + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v, serde_json::json!({"strokeBehindFill": true})); + assert_eq!(serde_json::from_value::(v).unwrap(), a); + let mut p = CharProps::default(); + p.apply(&a); + assert!(p.stroke_behind_fill); +} + +#[test] +fn digits_map_to_their_script() { + assert_eq!(Digits::Default.map('7', "Arabic"), '7'); + assert_eq!(Digits::Hindi.map('3', ""), '\u{0663}'); + assert_eq!(Digits::Farsi.map('0', ""), '\u{06F0}'); + assert_eq!(Digits::Native.map('5', "Thai"), '\u{0E55}'); + assert_eq!(Digits::Native.map('5', "English: USA"), '5'); + assert_eq!(Digits::Hindi.map('x', ""), 'x'); +} + +#[test] +fn language_names_map_to_bcp47_tags() { + assert_eq!(language_tag("English: USA"), Some("en-US")); + assert_eq!(language_tag("English: Australian"), Some("en")); + assert_eq!(language_tag("Turkish"), Some("tr")); + assert_eq!(language_tag("Georgian"), Some("ka")); + assert_eq!(language_tag("Japanese"), Some("ja")); + assert_eq!(language_tag("Korean"), Some("ko")); + assert_eq!(language_tag("Chinese"), Some("zh")); + assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans")); + assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant")); + assert_eq!(language_tag("Arabic"), Some("ar")); + assert_eq!(language_tag("Persian"), Some("fa")); + assert_eq!(language_tag("Farsi"), Some("fa")); + assert_eq!(language_tag("Urdu"), Some("ur")); + assert_eq!(language_tag("Hebrew"), Some("he")); + assert_eq!(language_tag("[No Language]"), None); + assert_eq!(language_tag("Klingon"), None); + assert_eq!(language_tag(""), None); +} + +#[test] +fn chinese_japanese_and_korean_names_map_in_their_common_spellings() { + for (name, tag) in [ + ("Chinese: Simplified", Some("zh-Hans")), + ("Simplified Chinese", Some("zh-Hans")), + ("Chinese Simplified", Some("zh-Hans")), + ("Chinese (Simplified)", Some("zh-Hans")), + (" chinese: SIMPLIFIED ", Some("zh-Hans")), + ("zh_CN", Some("zh-Hans")), + ("zh-CN", Some("zh-Hans")), + ("zh-Hans", Some("zh-Hans")), + ("zh-Hans-CN", Some("zh-Hans")), + ("Chinese: Traditional", Some("zh-Hant")), + ("Traditional Chinese", Some("zh-Hant")), + ("Chinese Traditional", Some("zh-Hant")), + ("Chinese (Traditional)", Some("zh-Hant")), + ("zh_TW", Some("zh-Hant")), + ("zh-TW", Some("zh-Hant")), + ("zh_HK", Some("zh-Hant")), + ("zh-HK", Some("zh-Hant")), + ("zh-Hant", Some("zh-Hant")), + ("Chinese", Some("zh")), + ("zh", Some("zh")), + ("Japanese", Some("ja")), + ("ja", Some("ja")), + ("ja_JP", Some("ja-JP")), + ("ja-JP", Some("ja-JP")), + ("Korean", Some("ko")), + ("ko_KR", Some("ko-KR")), + ("ko-KR", Some("ko-KR")), + ("English: USA", Some("en-US")), + ("German: Swiss 2006 Reform", Some("de-CH")), + ("Norwegian: Bokmål", Some("nb")), + ("[No Language]", None), + ] { + assert_eq!(language_tag(name), tag, "{name}"); + } +} + +#[test] +fn locale_codes_map_to_their_tags() { + for (name, tag) in [ + // As InDesign-exported IDML writes them (AppliedLanguage). + ("$ID/de_DE_2006", Some("de-DE")), + ("$ID/nl_NL_2005", Some("nl-NL")), + ("$ID/English: USA", Some("en-US")), + ("$ID/English: UK", Some("en-GB")), + ("$ID/Spanish: Castilian", Some("es-ES")), + ("$ID/French", Some("fr")), + // As IDML import keeps them. + ("de_DE_2006", Some("de-DE")), + ("nl_NL_2005", Some("nl-NL")), + ("Spanish: Castilian", Some("es-ES")), + ("French", Some("fr")), + // Other locale codes. + ("de-DE", Some("de-DE")), + ("de_CH_2006", Some("de-CH")), + ("pt_BR", Some("pt-BR")), + ("en_us", Some("en-US")), + ("es_419", Some("es-419")), + ("fil_PH", Some("fil-PH")), + ("sv", Some("sv")), + ("ja", Some("ja")), + // Not codes. + ("de_DE_2006_x", None), + ("d_DE", None), + ("de_D", None), + ("de_DE_06", None), + ("1e_DE", None), + ("[No Language]", None), + ] { + assert_eq!(language_tag(name), tag, "{name}"); + } + assert_eq!(language_subtag("de-DE"), "de"); + assert_eq!(language_subtag("zh-Hant"), "zh"); + assert_eq!(language_subtag("ko"), "ko"); +} + +#[test] +fn merge_and_resolve() { + let mut a = CharAttrs { size: Some(10.0), ..Default::default() }; + a.merge(&CharAttrs { tracking: Some(20.0), size: Some(11.0), ..Default::default() }); + assert_eq!(a.size, Some(11.0)); + assert_eq!(a.count(), 2); + let mut p = CharProps::default(); + p.apply(&a); + assert_eq!(p.size, 11.0); + assert_eq!(p.tracking, 20.0); + assert_eq!(p.font_family, "Source Serif 4"); +} + +#[test] +fn tab_lists_are_checked() { + let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + assert!(TabStop::checked_list(vec![stop(f64::NAN)]).is_err()); + assert!(TabStop::checked_list(vec![stop(f64::INFINITY)]).is_err()); + assert!(TabStop::checked_list((0..=MAX_TAB_STOPS).map(|i| stop(i as f64)).collect()).is_err()); + let list = TabStop::checked_list(vec![stop(50.0), TabStop { leader: ".".into(), ..stop(10.0) }, stop(50.001)]).unwrap(); + assert_eq!(list.iter().map(|t| t.position).collect::>(), [10.0, 50.001], "sorted, one stop per position"); +} + +/// A file's tab list is repaired, never refused; a list the commands accept comes through +/// unchanged. +#[test] +fn tab_lists_from_files_are_sanitized() { + let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + let mut tabs: Vec = (0..500).rev().map(|i| stop(i as f64 * 10.0)).collect(); + tabs.push(stop(f64::NAN)); + tabs.push(stop(f64::NEG_INFINITY)); + tabs.push(TabStop { leader: "\u{7}0123456789".into(), align: TabAlign::Char, align_on: "\n,;".into(), ..stop(-3.0) }); + let list = TabStop::sanitized_list(tabs); + assert_eq!(list.len(), MAX_TAB_STOPS); + assert!(list.windows(2).all(|w| w[1].position - w[0].position >= SAME_TAB_POSITION), "sorted, one stop per position"); + let first = &list[0]; + assert_eq!((first.position, first.leader.as_str(), first.align_on.as_str()), (0.0, "01234567", ","), "the later stop at 0 wins"); + assert_eq!(list.last().map(|t| t.position), Some(990.0), "the first stops by position are kept"); + + let ok = TabStop::checked_list(vec![stop(72.0), TabStop { align: TabAlign::Char, ..stop(10.0) }]).unwrap(); + assert_eq!(TabStop::sanitized_list(ok.clone()), ok); + + // `null` is how JSON writes a non-finite position; that stop is dropped, the others kept. + let json = r#"{"tabs":[{"position":null},{"position":20000,"leader":"0123456789"},{"position":5}]}"#; + let a: ParaAttrs = serde_json::from_str(json).unwrap(); + let tabs = a.tabs.unwrap(); + assert_eq!(tabs.iter().map(|t| t.position).collect::>(), [5.0, MAX_TAB_POSITION]); + assert_eq!(tabs[1].leader, "01234567"); + let p: ParaProps = serde_json::from_str(json).unwrap(); + assert_eq!(p.tabs, tabs); + assert!(serde_json::from_str::("{}").is_err(), "a stop names its position"); +} + +#[test] +fn set_json_by_name() { + let mut a = ParaAttrs::default(); + a.set_json("spaceBefore", &serde_json::json!(6.0)).unwrap(); + a.set_json("align", &serde_json::json!("center")).unwrap(); + a.set_json("left_indent", &serde_json::json!(12)).unwrap(); + assert_eq!(a.space_before, Some(6.0)); + assert_eq!(a.align, Some(Align::Center)); + assert_eq!(a.left_indent, Some(12.0)); + assert!(a.set_json("nope", &serde_json::json!(1)).is_err()); + a.set_json("align", &serde_json::Value::Null).unwrap(); + assert_eq!(a.align, None); +} + +#[test] +fn delta_and_common() { + let a = CharProps::default(); + let mut b = a.clone(); + b.size = 18.0; + let d = a.delta(&b); + assert_eq!(d.count(), 1); + let c = CharProps::common([&a, &b]); + assert_eq!(c.size, None); + assert_eq!(c.font_family.as_deref(), Some("Source Serif 4")); +} + +#[test] +fn serde_skips_unset() { + let a = CharAttrs { size: Some(9.0), ..Default::default() }; + assert_eq!(serde_json::to_string(&a).unwrap(), r#"{"size":9.0}"#); + let p: ParaProps = serde_json::from_str(r#"{"align":"center"}"#).unwrap(); + assert_eq!(p.align, Align::Center); + assert_eq!(p.word_space_max, 1.33); +} + +#[test] +fn same_para_style_spacing_ignore_survives_serde() { + for v in [None, Some(None), Some(Some(0.0))] { + let a = ParaAttrs { same_para_style_spacing: v, ..Default::default() }; + let back: ParaAttrs = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); + assert_eq!(back.same_para_style_spacing, v); + } +} + +#[test] +fn numbering_styles() { + assert_eq!(NumberStyle::UpperRoman.format(14), "XIV"); + assert_eq!(NumberStyle::LowerRoman.format(9), "ix"); + assert_eq!(NumberStyle::UpperLetters.format(1), "A"); + assert_eq!(NumberStyle::UpperLetters.format(27), "AA"); + assert_eq!(NumberStyle::LowerLetters.format(28), "bb"); + assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03"); + assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007"); + assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012"); + // Repeating forms stay small for huge numbers. + assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string()); +} + +#[test] +fn kanji_numbers_are_written_digit_by_digit() { + for (n, s) in [ + (0, "〇"), + (1, "一"), + (5, "五"), + (10, "一〇"), + (11, "一一"), + (20, "二〇"), + (38, "三八"), + (99, "九九"), + (100, "一〇〇"), + (105, "一〇五"), + (1000, "一〇〇〇"), + (2026, "二〇二六"), + (u32::MAX, "四二九四九六七二九五"), + ] { + assert_eq!(NumberStyle::Kanji.format(n), s, "{n}"); + } +} + +#[test] +fn list_labels_expand_metacharacters() { + let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() }; + assert_eq!(p.number_label(2), "B.\t"); + let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() }; + assert_eq!(p.number_label(3), "Tabel 3\t"); + assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^"); + assert_eq!(expand_list_text("a\nb^p", "1"), "ab"); + assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t"); +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index c97d2ec797..1d2feb6a87 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -9,1416 +9,38 @@ use serde::{Deserialize, Serialize}; use crate::ids::{AssetId, ItemId, LayerId, StoryId}; -/// Fill: a swatch reference and tint (InDesign fills always go through swatches or unnamed colours). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Fill { - pub swatch: String, - pub tint: f32, - /// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds). - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_angle: Option, - /// Gradient Swatch tool vector in the item's own space: start and end of a linear gradient, - /// centre and radius point of a radial one (`None` = fit to bounds at `gradient_angle`). - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_vector: Option<[f64; 4]>, - pub overprint: bool, -} - -impl Default for Fill { - fn default() -> Self { - Fill::none() - } -} - -impl Fill { - /// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and - /// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's - /// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box); - /// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's - /// length, measured with the box made square, sets its size. - pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) { - let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 }; - match self.gradient_vector { - Some([x0, y0, x1, y1]) => { - let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot(); - (designcraft_geom::Point::new(x0, y0), r, r * k) - } - // A box with no width or height (a straight line's) has no ellipse: a circle across - // its length. - None if b.width() <= 1e-9 || b.height() <= 1e-9 => { - let r = b.width().max(b.height()).max(1e-3) / 2.0; - (b.center(), r, r) - } - None => (b.center(), b.width() / 2.0, b.height() / 2.0), - } - } - - /// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the - /// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its - /// centre. A box with no extent along that angle (a straight line, across it) takes its longer - /// side. - pub fn linear_axis(&self, b: Rect) -> (Point, Point) { - if let Some([x0, y0, x1, y1]) = self.gradient_vector { - return (Point::new(x0, y0), Point::new(x1, y1)); - } - let a = self.gradient_angle.unwrap_or(0.0).to_radians(); - let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0; - let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 }; - let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half; - (b.center() - d, b.center() + d) - } - - pub fn none() -> Self { - Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } - } - pub fn swatch(name: &str) -> Self { - Fill { swatch: name.into(), ..Fill::none() } - } - pub fn is_none(&self) -> bool { - self.swatch == designcraft_color::swatch::NONE - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StrokeAlign { - #[default] - Center, - Inside, - Outside, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Cap { - #[default] - Butt, - Round, - Projecting, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Join { - #[default] - Miter, - Round, - Bevel, -} - -/// The shortest non-zero dash or gap a command accepts, in points. -pub const MIN_DASH: f64 = 0.1; -/// The longest dash or gap a command accepts, in points. -pub const MAX_DASH: f64 = 10_000.0; -/// The most lengths a dash pattern holds. -pub const MAX_DASH_ENTRIES: usize = 64; -/// The most pattern repeats a renderer expands along one path; finer patterns draw solid. -pub const MAX_DASH_PERIODS: f64 = 20_000.0; - -/// An upper bound of the length of `bp`: the length of its control polygon. -pub fn path_length_bound(bp: &designcraft_geom::BezPath) -> f64 { - use designcraft_geom::PathSeg; - bp.segments() - .map(|s| match s { - PathSeg::Line(l) => l.p0.distance(l.p1), - PathSeg::Quad(q) => q.p0.distance(q.p1) + q.p1.distance(q.p2), - PathSeg::Cubic(c) => c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3), - }) - .sum() -} - -/// Stroke types (Stroke panel → Type). Dash/gap arrays are in points. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum StrokeType { - #[default] - Solid, - Dashed { - pattern: Vec, - }, - Dotted, - ThickThin, - ThinThick, - ThinThin, - ThickThick, - ThinThickThin, - ThickThinThick, - Wavy, - Hashed, - /// A custom striped stroke: bands (start, width) as fractions of the weight from the left edge. - Stripes { - bands: Vec<(f64, f64)>, - }, - /// A named stroke style of the document (Stroke Styles), by name. - Style { - name: String, - }, -} - -impl StrokeType { - pub fn label(&self) -> &'static str { - match self { - StrokeType::Solid => "Solid", - StrokeType::Dashed { .. } => "Dashed", - StrokeType::Dotted => "Dotted", - StrokeType::ThickThin => "Thick - Thin", - StrokeType::ThinThick => "Thin - Thick", - StrokeType::ThinThin => "Thin - Thin", - StrokeType::ThickThick => "Thick - Thick", - StrokeType::ThinThickThin => "Thin - Thick - Thin", - StrokeType::ThickThinThick => "Thick - Thin - Thick", - StrokeType::Wavy => "Wavy", - StrokeType::Hashed => "Straight Hash", - StrokeType::Stripes { .. } => "Stripes", - StrokeType::Style { .. } => "Custom", - } - } - - /// Whether `pattern` is a dash pattern commands accept: 1 to [`MAX_DASH_ENTRIES`] lengths, - /// each 0 or from [`MIN_DASH`] to [`MAX_DASH`] points, not all 0. - pub fn valid_dash_pattern(pattern: &[f64]) -> bool { - !pattern.is_empty() - && pattern.len() <= MAX_DASH_ENTRIES - && pattern.iter().all(|x| *x == 0.0 || (MIN_DASH..=MAX_DASH).contains(x)) - && pattern.iter().any(|x| *x > 0.0) - } - - /// Whether a renderer may expand `pattern` along a path at most `length` points long: at - /// most [`MAX_DASH_ENTRIES`] finite, non-negative lengths that repeat at most - /// [`MAX_DASH_PERIODS`] times. Otherwise the stroke draws solid, so a degenerate pattern - /// from a file can't stall rendering. - pub fn expandable_dashes(pattern: &[f64], length: f64) -> bool { - let period: f64 = pattern.iter().sum(); - pattern.len() <= MAX_DASH_ENTRIES - && pattern.iter().all(|x| x.is_finite() && *x >= 0.0) - && period > 0.0 - && length.is_finite() - && length / period <= MAX_DASH_PERIODS - } - - /// The bands of a striped type (start, width as fractions of the weight). - pub fn bands(&self) -> Option> { - Some(match self { - StrokeType::ThickThin => vec![(0.0, 0.5), (0.75, 0.25)], - StrokeType::ThinThick => vec![(0.0, 0.25), (0.5, 0.5)], - StrokeType::ThinThin => vec![(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0 / 3.0)], - StrokeType::ThickThick => vec![(0.0, 0.4), (0.6, 0.4)], - StrokeType::ThinThickThin => vec![(0.0, 0.2), (0.35, 0.3), (0.8, 0.2)], - StrokeType::ThickThinThick => vec![(0.0, 0.35), (0.45, 0.1), (0.65, 0.35)], - StrokeType::Stripes { bands } => bands.clone(), - _ => return None, - }) - } - - /// The filled area for types drawn as fills (stripes, wavy, hash); `None` for solid, dashed - /// and dotted strokes (and unresolved named styles). - pub fn outline(&self, bp: &designcraft_geom::BezPath, weight: f64, tol: f64) -> Option { - use designcraft_geom::stroke_style as ss; - match self { - StrokeType::Wavy => Some(ss::wavy(bp, weight, tol)), - StrokeType::Hashed => Some(ss::hashed(bp, weight, tol)), - t => t.bands().map(|b| ss::stripes(bp, weight, &b, tol)), - } - } -} - -/// A named custom stroke style (Stroke Styles dialog): dashes, dots or stripes. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StrokeStyleDef { - pub name: String, - pub kind: StrokeType, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Arrowhead { - #[default] - None, - SimpleWide, - Simple, - Triangle, - TriangleWide, - Barbed, - Curved, - Circle, - CircleSolid, - Square, - SquareSolid, - Bar, -} - -impl Arrowhead { - pub const ALL: [Arrowhead; 12] = [ - Arrowhead::None, - Arrowhead::Simple, - Arrowhead::SimpleWide, - Arrowhead::Triangle, - Arrowhead::TriangleWide, - Arrowhead::Barbed, - Arrowhead::Curved, - Arrowhead::Circle, - Arrowhead::CircleSolid, - Arrowhead::Square, - Arrowhead::SquareSolid, - Arrowhead::Bar, - ]; - - pub fn label(self) -> &'static str { - match self { - Arrowhead::None => "None", - Arrowhead::Simple => "Simple", - Arrowhead::SimpleWide => "Simple - Wide", - Arrowhead::Triangle => "Triangle", - Arrowhead::TriangleWide => "Triangle - Wide", - Arrowhead::Barbed => "Barbed", - Arrowhead::Curved => "Curved", - Arrowhead::Circle => "Circle", - Arrowhead::CircleSolid => "Circle - Solid", - Arrowhead::Square => "Square", - Arrowhead::SquareSolid => "Square - Solid", - Arrowhead::Bar => "Bar", - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Stroke { - pub swatch: String, - pub tint: f32, - pub weight: f64, - #[serde(rename = "type")] - pub kind: StrokeType, - pub align: StrokeAlign, - pub cap: Cap, - pub join: Join, - pub miter_limit: f64, - pub start: Arrowhead, - pub end: Arrowhead, - /// Gap colour for dashed/striped strokes. - pub gap_swatch: String, - pub gap_tint: f32, - pub overprint: bool, - /// Overprint Gap (Attributes panel). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub gap_overprint: bool, - /// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_angle: Option, - /// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_vector: Option<[f64; 4]>, -} - -impl Default for Stroke { - /// InDesign's default for drawn shapes: 1 pt black. - fn default() -> Self { - Stroke { - swatch: designcraft_color::swatch::BLACK.into(), - tint: 1.0, - weight: 1.0, - kind: StrokeType::Solid, - align: StrokeAlign::Center, - cap: Cap::Butt, - join: Join::Miter, - miter_limit: 4.0, - start: Arrowhead::None, - end: Arrowhead::None, - gap_swatch: designcraft_color::swatch::NONE.into(), - gap_tint: 1.0, - overprint: false, - gap_overprint: false, - gradient_angle: None, - gradient_vector: None, - } - } -} - -impl Stroke { - /// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way: - /// a gradient swatch on a stroke runs across the object's box as on its fill. - pub fn as_fill(&self) -> Fill { - Fill { - swatch: self.swatch.clone(), - tint: self.tint, - gradient_angle: self.gradient_angle, - gradient_vector: self.gradient_vector, - overprint: self.overprint, - } - } - - /// How far the painted stroke can reach beyond the path (arrowheads reach further). - pub fn extent(&self) -> f64 { - if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 } - } - - pub fn none() -> Self { - Stroke { swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() } - } - pub fn is_none(&self) -> bool { - self.swatch == designcraft_color::swatch::NONE || self.weight <= 0.0 - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum VerticalJustification { - #[default] - Top, - Center, - Bottom, - Justify, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FirstBaseline { - #[default] - Ascent, - CapHeight, - Leading, - XHeight, - Fixed, - /// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo - /// ascender when the typo ascender and descender span one em; else half an em above half the - /// cap height. - EmboxHeight, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AutoSize { - #[default] - Off, - HeightOnly, - WidthOnly, - HeightAndWidth, - HeightAndWidthProportionally, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ColumnsKind { - #[default] - FixedNumber, - FixedWidth, - FlexibleWidth, -} - -/// Text Frame Options. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct TextFrameOptions { - pub columns: u32, - pub columns_kind: ColumnsKind, - pub gutter: f64, - /// Column width for fixed/flexible width columns. - pub column_width: f64, - pub balance_columns: bool, - /// Inset spacing: top, left, bottom, right. - pub inset: [f64; 4], - pub vertical_justification: VerticalJustification, - pub vj_paragraph_spacing_limit: f64, - pub first_baseline: FirstBaseline, - pub first_baseline_min: f64, - pub ignore_wrap: bool, - pub auto_size: AutoSize, - /// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing. - pub auto_size_ref: u8, - /// Auto-size never makes the frame shorter than `auto_size_min_height` while this is on. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub auto_size_use_min_height: bool, - #[serde(skip_serializing_if = "is_default")] - pub auto_size_min_height: f64, - /// Auto-size never makes the frame narrower than `auto_size_min_width` while this is on. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub auto_size_use_min_width: bool, - #[serde(skip_serializing_if = "is_default")] - pub auto_size_min_width: f64, - /// Width-growing auto-size sets each paragraph on one line (the frame widens to the longest). - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub auto_size_no_line_breaks: bool, - pub column_rule: bool, - pub column_rule_weight: f64, - pub column_rule_color: String, - /// 0..1. - pub column_rule_tint: f32, - /// Horizontal offset of each rule from the middle of its gutter. - pub column_rule_offset: f64, - /// The rule starts this far below the column top and ends this far above its bottom. - pub column_rule_top_inset: f64, - pub column_rule_bottom_inset: f64, - /// Use a custom baseline grid for this frame. - #[serde(skip_serializing_if = "Option::is_none")] - pub baseline_grid: Option, - /// Type on a Path: the text runs along the item's path instead of filling it. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the -/// top of the page, otherwise as `[start, increment, relativeTo]`. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct FrameBaselineGrid { - /// The first grid line, down from the line `relative_to` names. - pub start: f64, - /// The distance between grid lines; 0 or less turns the grid off. - pub increment: f64, - pub relative_to: FrameGridRelative, -} - -impl FrameBaselineGrid { - /// A grid measured from the top of the page. - pub fn new(start: f64, increment: f64) -> Self { - FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage } - } -} - -/// What a frame's custom baseline grid's start is measured from. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum FrameGridRelative { - #[default] - #[serde(rename = "page")] - TopOfPage, - #[serde(rename = "margin")] - TopMargin, - #[serde(rename = "frame")] - TopOfFrame, - #[serde(rename = "inset")] - TopInset, -} - -impl FrameGridRelative { - pub const ALL: [FrameGridRelative; 4] = - [FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset]; - - /// The name commands and native files use: page, margin, frame or inset. - pub fn name(self) -> &'static str { - match self { - FrameGridRelative::TopOfPage => "page", - FrameGridRelative::TopMargin => "margin", - FrameGridRelative::TopOfFrame => "frame", - FrameGridRelative::TopInset => "inset", - } - } - - pub fn from_name(name: &str) -> Option { - Self::ALL.into_iter().find(|r| r.name() == name) - } -} - -impl Serialize for FrameBaselineGrid { - fn serialize(&self, s: S) -> Result { - // Files saved before Relative To existed hold two elements; a page-relative grid keeps that form. - match self.relative_to { - FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s), - r => (self.start, self.increment, r).serialize(s), - } - } -} - -impl<'de> Deserialize<'de> for FrameBaselineGrid { - fn deserialize>(d: D) -> Result { - struct Grid; - impl<'de> serde::de::Visitor<'de> for Grid { - type Value = FrameBaselineGrid; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("[start, increment] or [start, increment, relativeTo]") - } - fn visit_seq>(self, mut a: A) -> Result { - use serde::de::Error; - let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; - let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; - let relative_to = a.next_element()?.unwrap_or_default(); - if a.next_element::()?.is_some() { - return Err(A::Error::invalid_length(4, &self)); - } - Ok(FrameBaselineGrid { start, increment, relative_to }) - } - } - d.deserialize_seq(Grid) - } -} - -/// Type on a Path Options. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct PathType { - /// Where the text starts, as a distance along the path (the start bracket). - pub start: f64, - /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's - /// end. Text past it is overset. - #[serde(skip_serializing_if = "Option::is_none")] - pub end: Option, - /// Run the other way (the text sits on the other side). - pub flip: bool, - pub align: PathAlign, -} - -/// Which part of the type sits on the path. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PathAlign { - #[default] - Baseline, - Ascender, - Descender, - Center, -} - -impl Default for TextFrameOptions { - fn default() -> Self { - TextFrameOptions { - columns: 1, - columns_kind: ColumnsKind::FixedNumber, - gutter: 12.0, - column_width: 0.0, - balance_columns: false, - inset: [0.0; 4], - vertical_justification: VerticalJustification::Top, - vj_paragraph_spacing_limit: 0.0, - first_baseline: FirstBaseline::Ascent, - first_baseline_min: 0.0, - ignore_wrap: false, - auto_size: AutoSize::Off, - auto_size_ref: 4, - auto_size_use_min_height: false, - auto_size_min_height: 0.0, - auto_size_use_min_width: false, - auto_size_min_width: 0.0, - auto_size_no_line_breaks: false, - column_rule: false, - column_rule_weight: 1.0, - column_rule_color: designcraft_color::swatch::BLACK.into(), - column_rule_tint: 1.0, - column_rule_offset: 0.0, - column_rule_top_inset: 0.0, - column_rule_bottom_inset: 0.0, - baseline_grid: None, - path: None, - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WrapMode { - #[default] - None, - BoundingBox, - Contour, - JumpObject, - JumpToNextColumn, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WrapSide { - #[default] - BothSides, - RightSide, - LeftSide, - TowardsSpine, - AwayFromSpine, - LargestArea, -} - -/// Text Wrap panel › Contour Options › Type: the outline a contour wrap follows on a graphic frame. -/// Other frames always wrap around their own path. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ContourType { - /// The placed graphic's rectangle (within the frame). - BoundingBox, - /// Edges detected in the image. Composition follows the frame path. - DetectEdges, - /// The image's alpha channel. Composition follows the frame path. - AlphaChannel, - /// A path saved in the image. Composition follows the frame path. - PhotoshopPath, - /// The frame's path. - GraphicFrame, - /// The graphic's clipping path, or the frame's path when it has none. - #[default] - SameAsClipping, -} - -/// Text Wrap panel settings. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct TextWrap { - pub mode: WrapMode, - /// Top, left, bottom, right offsets. - pub offsets: [f64; 4], - pub invert: bool, - pub side: WrapSide, - pub contour: ContourType, - /// A custom wrap boundary in the item's inner space, offsets already included. Bounding-box - /// and contour wraps follow it instead of the frame; other modes use its bounds. Resizing the - /// item transforms it with the path; other path edits keep it as it is. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -impl TextWrap { - pub fn is_default(&self) -> bool { - *self == TextWrap::default() - } -} - -/// Frame fitting options (Object → Fitting → Frame Fitting Options). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Fitting { - #[default] - None, - FillProportionally, - FitProportionally, - FitContentToFrame, - CenterContent, -} - -/// Retained IDML layer choices for one placement, independent of shared asset bytes. -/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy. -/// XML Self IDs are regenerated by the exporter and are not stored here. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GraphicLayerOptions { - pub attributes: std::collections::BTreeMap, - pub layers: Vec>, - /// Parent indices in pre-order; absent entries are top-level layers (older native files). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub parents: Vec>, -} - -pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = - "Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output"; - -impl GraphicLayerOptions { - pub fn has_visibility_overrides(&self) -> bool { - self.layers.iter().any(|layer| { - layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility")) - || ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true")) - }) - } -} - -/// A placed graphic inside a frame. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Graphic { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer_options: Option, - pub asset: AssetId, - /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). - pub size: (f64, f64), - /// Graphic space (0,0)-(w,h) → frame inner space. - pub xf: Affine, - /// Clipping path: only the part of the graphic inside it shows. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clip: Option>, - #[serde(default)] - pub auto_fit: Fitting, - /// Frame Fitting Options › Align From: the reference point (0–8, row-major; 4 = centre). - #[serde(default = "center_ref")] - pub fit_align: u8, - /// Frame Fitting Options › Crop Amount: top, left, bottom, right (negative adds space). - #[serde(default)] - pub crop: [f64; 4], - /// Text wrap set on the graphic itself (Direct Selection), around the part its frame shows. - #[serde(default, skip_serializing_if = "TextWrap::is_default")] - pub wrap: TextWrap, - /// The swatch applied to the image itself: InDesign colours a greyscale or bitmap image with - /// it, colour images ignore it. `None` (the default, like InDesign's Black) leaves it as is. - #[serde(default, skip_serializing_if = "Fill::is_none")] - pub fill: Fill, -} - -fn center_ref() -> u8 { - 4 -} - -/// Most anchors a clipping path read from a file keeps (detected edges can be dense). -pub const MAX_CLIP_ANCHORS: usize = 100_000; - -/// Clipping Path › Type: where the clipping path came from. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ClippingKind { - /// Traced from the image's edges against a light background. - DetectEdges, - /// Traced from one of the image's alpha channels. - AlphaChannel, - /// A path saved in the image file. - PhotoshopPath, - /// One of the above, edited by hand. - UserModifiedPath, -} - -/// A placed graphic's clipping path. The tracing options are kept as the file had them; the -/// outline itself is `path`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClippingPath { - pub kind: ClippingKind, - /// The outline in graphic space ((0,0)-(w,h) is the graphic's natural rectangle). - pub path: PathData, - /// Show the graphic outside the path instead of inside it. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub invert: bool, - /// Holes inside the traced shape are part of the path. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub include_inside_edges: bool, - /// Detect Edges: trace only the part of the image the frame shows. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub restrict_to_frame: bool, - /// Trace the full-resolution image rather than the screen proxy. - #[serde(default = "yes")] - pub use_high_resolution: bool, - /// Detect Edges / Alpha Channel: the darkness or opacity level that counts as inside (0–255). - #[serde(default = "clip_threshold")] - pub threshold: f64, - /// How closely the traced path follows the pixels, in pixels. - #[serde(default = "clip_tolerance")] - pub tolerance: f64, - /// Inset Frame: how far the path is pulled in (points; negative pushes it out). - #[serde(default)] - pub inset: f64, - /// The alpha channel or image path the clip came from. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub source_name: String, - /// Index of the alpha channel (-1 = none). - #[serde(default = "no_index")] - pub source_index: i32, -} - -fn yes() -> bool { - true -} -fn clip_threshold() -> f64 { - 25.0 -} -fn clip_tolerance() -> f64 { - 2.0 -} -fn no_index() -> i32 { - -1 -} - -impl ClippingPath { - /// A clipping path of `kind` with the default options. - pub fn new(kind: ClippingKind, path: PathData) -> Self { - Self { - kind, - path, - invert: false, - include_inside_edges: false, - restrict_to_frame: false, - use_high_resolution: true, - threshold: clip_threshold(), - tolerance: clip_tolerance(), - inset: 0.0, - source_name: String::new(), - source_index: no_index(), - } - } -} - -/// Liquid Layout object rules: which edges stay at their distance from the page edge, and -/// whether the object may stretch. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct ObjectLiquid { - pub resize_width: bool, - pub resize_height: bool, - pub pin_top: bool, - pub pin_bottom: bool, - pub pin_left: bool, - pub pin_right: bool, -} - -/// Object Export Options: tagged PDF and EPUB/HTML handling of one object. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct ExportOptions { - /// Tagged PDF: the object is decoration (an artifact), not content. - pub artifact: bool, - /// EPUB/HTML: export the object as an image (whatever it is). - pub rasterize: bool, - /// EPUB/HTML: "left", "center" or "right" ("" = as the flow puts it). - pub align: String, - /// EPUB/HTML: start a new page (screen) before the object. - pub page_break_before: bool, -} - -impl ExportOptions { - pub fn is_default(&self) -> bool { - *self == ExportOptions::default() - } -} - -/// A PDF form field. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FormField { - pub kind: FieldKind, - /// Field name (unique in the exported PDF). - pub name: String, - /// Default value: the text, the chosen option, or "On"/"" for a check box. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub value: String, - /// Choices of a combo box or list box. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub multiline: bool, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub required: bool, - /// Font size of the field's text (0 = auto). - #[serde(default)] - pub font_size: f64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FieldKind { - TextField, - CheckBox, - ComboBox, - ListBox, - Signature, -} - -/// The source and template of a live caption. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LiveCaption { - pub source: ItemId, - pub template: String, -} - -/// Media panel options for a placed video or sound. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct MediaOptions { - pub play_on_page_load: bool, - #[serde(rename = "loop")] - pub looping: bool, - /// Show the player controls. - pub controls: bool, - /// Poster image (shown on the page and in print); `None` = a placeholder frame. - #[serde(skip_serializing_if = "Option::is_none")] - pub poster: Option, -} - -impl Item { - /// The graphic to draw for this frame: a media frame with a poster draws the poster - /// stretched over the media rectangle. - pub fn drawn_graphic(&self, assets: &std::collections::BTreeMap>) -> Option { - let Content::Graphic(g) = &self.content else { return None }; - if let Some(poster) = self.media.as_ref().and_then(|m| m.poster) - && let Some((pw, ph)) = assets.get(&poster).and_then(|a| a.pixels) - { - let (pw, ph) = (pw.max(1) as f64, ph.max(1) as f64); - return Some(Graphic { asset: poster, size: (pw, ph), xf: g.xf * Affine::scale_non_uniform(g.size.0 / pw, g.size.1 / ph), ..g.clone() }); - } - Some(g.clone()) - } -} - -/// Vertical text: composed in a box `area.height()` wide and `area.width()` tall at the origin, -/// placed so lines go down and follow each other leftwards (a clockwise quarter turn). -pub fn vertical_text_xf(area: Rect) -> Affine { - Affine::new([0.0, 1.0, -1.0, 0.0, area.x1, area.y0]) -} - -/// "video" or "audio" for a media MIME type. -pub fn media_kind(mime: &str) -> Option<&'static str> { - if mime.starts_with("video/") { - Some("video") - } else if mime.starts_with("audio/") { - Some("audio") - } else { - None - } -} - -/// MIME type of a media file by its extension. -pub fn media_mime(name: &str) -> Option<&'static str> { - let ext = name.rsplit('.').next()?.to_ascii_lowercase(); - Some(match ext.as_str() { - "mp4" | "m4v" => "video/mp4", - "mov" => "video/quicktime", - "webm" => "video/webm", - "ogv" => "video/ogg", - "mp3" => "audio/mpeg", - "m4a" => "audio/mp4", - "wav" => "audio/wav", - "ogg" | "oga" => "audio/ogg", - "aac" => "audio/aac", - _ => return None, - }) -} - -/// A button's On Release action. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum ButtonAction { - GoToPage { page: usize }, - GoToFirstPage, - GoToLastPage, - GoToNextPage, - GoToPreviousPage, - GoToUrl { url: String }, -} +mod content; +mod effects; +mod graphic; +mod paint; +mod text_frame; + +pub use content::{Content, Shape}; +pub use effects::{ + Bevel, DirectionalFeather, DropShadow, Effects, FeatherCorners, FeatherOptions, GlowTechnique, GradientFeather, InnerGlow, InnerShadow, + OuterGlow, Satin, +}; +pub use graphic::{ + ButtonAction, ClippingKind, ClippingPath, ExportOptions, FieldKind, Fitting, FormField, GRAPHIC_LAYER_RENDERING_WARNING, Graphic, + GraphicLayerOptions, LiveCaption, MAX_CLIP_ANCHORS, MediaOptions, ObjectLiquid, media_kind, media_mime, +}; +pub use paint::{ + Arrowhead, Cap, Fill, Join, MAX_DASH, MAX_DASH_ENTRIES, MAX_DASH_PERIODS, MIN_DASH, Stroke, StrokeAlign, StrokeStyleDef, StrokeType, + path_length_bound, +}; +pub use text_frame::{ + AutoSize, ColumnsKind, ContourType, FirstBaseline, FrameBaselineGrid, FrameGridRelative, PathAlign, PathType, TextFrame, TextFrameOptions, + TextWrap, VerticalJustification, WrapMode, WrapSide, vertical_text_xf, +}; fn is_zero_usize(v: &usize) -> bool { *v == 0 } -impl Graphic { - /// The part of the graphic its frame shows, in the frame's inner space (bounding boxes); - /// None when nothing shows. - pub fn shown_bounds(&self, frame: Rect) -> Option { - let r = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, self.size.0, self.size.1)).intersect(frame); - (r.width() > 0.0 && r.height() > 0.0).then_some(r) - } - - /// The region the clipping path lets through, in frame inner space, and whether it is filled - /// even-odd (an inverted clip: the graphic's rectangle minus the path). `None` when the graphic - /// has no clipping path or the path is empty. - pub fn clip_region(&self) -> Option<(designcraft_geom::BezPath, bool)> { - use designcraft_geom::Shape; - let c = self.clip.as_ref().filter(|c| !c.path.is_empty())?; - let mut path = c.path.to_bezpath(); - if !c.invert { - path.apply_affine(self.xf); - return Some((path, false)); - } - let outer = Rect::new(0.0, 0.0, self.size.0, self.size.1).union(path.bounding_box()).inflate(1.0, 1.0); - let mut region = outer.to_path(0.1); - region.extend(path.iter()); - region.apply_affine(self.xf); - Some((region, true)) - } - - /// The graphic transform that fits it into `frame` (the frame's inner rect) with `mode`, - /// honouring the crop amounts and the reference point. - pub fn fitted(&self, frame: Rect, mode: Fitting) -> Option { - let (nw, nh) = self.size; - if nw <= 0.0 || nh <= 0.0 { - return None; - } - let [t, l, b, r] = self.crop; - let area = Rect::new(frame.x0 - l, frame.y0 - t, frame.x1 + r, frame.y1 + b); - if area.width() <= 0.0 || area.height() <= 0.0 { - return None; - } - let (fx, fy) = ([0.0, 0.5, 1.0][(self.fit_align % 3) as usize], [0.0, 0.5, 1.0][(self.fit_align / 3).min(2) as usize]); - let place = |w: f64, h: f64| (area.x0 + (area.width() - w) * fx, area.y0 + (area.height() - h) * fy); - Some(match mode { - Fitting::None => return None, - Fitting::FillProportionally | Fitting::FitProportionally => { - let k = if mode == Fitting::FillProportionally { - (area.width() / nw).max(area.height() / nh) - } else { - (area.width() / nw).min(area.height() / nh) - }; - Affine::translate(place(nw * k, nh * k)) * Affine::scale(k) - } - Fitting::FitContentToFrame => Affine::translate((area.x0, area.y0)) * Affine::scale_non_uniform(area.width() / nw, area.height() / nh), - Fitting::CenterContent => { - let cur = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); - let (x, y) = place(cur.width(), cur.height()); - Affine::translate((x - cur.x0, y - cur.y0)) * self.xf - } - }) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TextFrame { - pub story: StoryId, - #[serde(default)] - pub options: TextFrameOptions, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum Content { - #[default] - Unassigned, - Text(TextFrame), - Graphic(Graphic), - Group { - items: Vec>, - }, -} - -/// What kind of spline the item was drawn as (labels in the Layers panel, tool behaviours). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Shape { - #[default] - Rectangle, - Oval, - Polygon, - GraphicLine, - Path, - Group, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct DropShadow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub angle: f64, - /// Use Global Light (the document angle instead of `angle`). - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub global_light: bool, - pub distance: f64, - pub size: f64, - pub spread: f64, -} - -impl Default for DropShadow { - fn default() -> Self { - DropShadow { - on: false, - color: designcraft_color::swatch::BLACK.into(), - opacity: 0.75, - angle: 120.0, - distance: 7.0, - size: 5.0, - spread: 0.0, - global_light: false, - } - } -} - -/// Inner shadow: a blurred, offset shadow inside the object's edge. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct InnerShadow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub angle: f64, - /// Use Global Light (the document angle instead of `angle`). - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub global_light: bool, - pub distance: f64, - pub size: f64, - /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. - pub choke: f64, -} - -impl Default for InnerShadow { - fn default() -> Self { - InnerShadow { - on: false, - color: designcraft_color::swatch::BLACK.into(), - opacity: 0.75, - angle: 120.0, - distance: 7.0, - size: 7.0, - choke: 0.0, - global_light: false, - } - } -} - -/// Outer glow: a blurred silhouette around the object, without offset. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct OuterGlow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub size: f64, - /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. - pub spread: f64, -} - -impl Default for OuterGlow { - fn default() -> Self { - OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } - } -} - fn is_default(v: &T) -> bool { *v == T::default() } -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Effects { - /// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows - /// the silhouette instead of blurring it. - pub drop_shadow: DropShadow, - /// Basic feather width (0 = off). - pub feather: f64, - /// Basic feather's choke and corners. - #[serde(skip_serializing_if = "is_default")] - pub feather_options: FeatherOptions, - #[serde(skip_serializing_if = "is_default")] - pub inner_shadow: InnerShadow, - #[serde(skip_serializing_if = "is_default")] - pub outer_glow: OuterGlow, - #[serde(skip_serializing_if = "is_default")] - pub gradient_feather: GradientFeather, - #[serde(skip_serializing_if = "is_default")] - pub inner_glow: InnerGlow, - #[serde(skip_serializing_if = "is_default")] - pub bevel: Bevel, - #[serde(skip_serializing_if = "is_default")] - pub satin: Satin, - #[serde(skip_serializing_if = "is_default")] - pub directional_feather: DirectionalFeather, -} - -/// Basic Feather › Corners: how the fade follows the shape's corners. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FeatherCorners { - /// Mitred: the fade keeps the corners sharp. - Sharp, - /// The fade rounds the corners by its width. - Rounded, - /// From opaque to transparent, softly (InDesign's default). - #[default] - Diffused, -} - -/// Basic Feather options besides its width. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct FeatherOptions { - /// Percentage (0–100) of the width that stays opaque: the fade gets shorter. - pub choke: f64, - pub corners: FeatherCorners, -} - -/// Inner/Outer Glow › Technique. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum GlowTechnique { - /// A soft, blurred glow. - #[default] - Softer, - /// A glow that keeps closer to the shape's edges. - Precise, -} - -/// Directional Feather: each side fades over its own width (item space, before rotation). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct DirectionalFeather { - pub on: bool, - /// Top, left, bottom, right widths in points. - pub widths: [f64; 4], -} - -/// Inner Glow: a glow inside the edge (or from the centre). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct InnerGlow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub size: f64, - /// Percentage (0–100) of `size` that hardens the glow. - pub choke: f64, - /// Glow from the centre instead of the edges. - pub center: bool, - /// How the glow blends with the object (InDesign: Screen). - pub mode: BlendMode, - pub technique: GlowTechnique, -} - -impl Default for InnerGlow { - fn default() -> Self { - InnerGlow { - on: false, - color: designcraft_color::swatch::PAPER.into(), - opacity: 0.75, - size: 7.0, - choke: 0.0, - center: false, - mode: BlendMode::Screen, - technique: GlowTechnique::Softer, - } - } -} - -/// Bevel and Emboss (inner bevel): a highlight on the lit edges, a shadow on the others. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Bevel { - pub on: bool, - pub size: f64, - /// Depth in percent (scales the highlight and shadow offset). - pub depth: f64, - pub angle: f64, - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub global_light: bool, - pub highlight: String, - pub highlight_opacity: f32, - pub shadow: String, - pub shadow_opacity: f32, -} - -impl Default for Bevel { - fn default() -> Self { - Bevel { - on: false, - size: 7.0, - depth: 100.0, - angle: 120.0, - global_light: true, - highlight: designcraft_color::swatch::PAPER.into(), - highlight_opacity: 0.75, - shadow: designcraft_color::swatch::BLACK.into(), - shadow_opacity: 0.75, - } - } -} - -/// Satin: soft interior shading from two offset copies of the shape. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Satin { - pub on: bool, - pub color: String, - pub opacity: f32, - pub angle: f64, - pub distance: f64, - pub size: f64, - pub invert: bool, -} - -impl Default for Satin { - fn default() -> Self { - Satin { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.5, angle: 120.0, distance: 7.0, size: 7.0, invert: false } - } -} - -/// Gradient Feather: the object fades from `start` to `end` opacity along a gradient. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct GradientFeather { - pub on: bool, - pub radial: bool, - /// Degrees (linear), when no vector was dragged. - pub angle: f64, - pub start: f32, - pub end: f32, - /// Gradient Feather tool vector in the item's space: start and end (linear), centre and - /// radius point (radial). - #[serde(skip_serializing_if = "Option::is_none")] - pub vector: Option<[f64; 4]>, -} - -impl Default for GradientFeather { - fn default() -> Self { - GradientFeather { on: false, radial: false, angle: 0.0, start: 1.0, end: 0.0, vector: None } - } -} - -impl GradientFeather { - /// The gradient's two points in item space for an object with inner bounds `b`. - pub fn points(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, designcraft_geom::Point) { - use designcraft_geom::{Point, Vec2}; - if let Some([x0, y0, x1, y1]) = self.vector { - return (Point::new(x0, y0), Point::new(x1, y1)); - } - let c = b.center(); - if self.radial { - return (c, c + Vec2::new(b.width().max(b.height()) / 2.0, 0.0)); - } - let a = self.angle.to_radians(); - let d = Vec2::new(a.cos(), -a.sin()); - let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; - (c - d * half, c + d * half) - } -} - -impl Effects { - /// These effects with opacities in 0–1, percentages in 0–100 and sizes, widths and distances - /// not below 0, or an error naming the first value that isn't a finite number. - pub fn sanitized(mut self) -> Result { - fn finite(name: &str, v: f64) -> Result { - if v.is_finite() { Ok(v) } else { Err(format!("{name} must be a finite number")) } - } - let len = |name: &str, v: f64| finite(name, v).map(|v| v.max(0.0)); - let pct = |name: &str, v: f64| finite(name, v).map(|v| v.clamp(0.0, 100.0)); - let opacity = |name: &str, v: f32| finite(name, f64::from(v)).map(|v| v.clamp(0.0, 1.0) as f32); - let ds = &mut self.drop_shadow; - ds.opacity = opacity("dropShadow.opacity", ds.opacity)?; - ds.angle = finite("dropShadow.angle", ds.angle)?; - ds.distance = len("dropShadow.distance", ds.distance)?; - ds.size = len("dropShadow.size", ds.size)?; - ds.spread = pct("dropShadow.spread", ds.spread)?; - self.feather = len("feather", self.feather)?; - let e = &mut self.inner_shadow; - e.opacity = opacity("innerShadow.opacity", e.opacity)?; - e.angle = finite("innerShadow.angle", e.angle)?; - e.distance = len("innerShadow.distance", e.distance)?; - e.size = len("innerShadow.size", e.size)?; - e.choke = pct("innerShadow.choke", e.choke)?; - let e = &mut self.outer_glow; - e.opacity = opacity("outerGlow.opacity", e.opacity)?; - e.size = len("outerGlow.size", e.size)?; - e.spread = pct("outerGlow.spread", e.spread)?; - let e = &mut self.gradient_feather; - e.angle = finite("gradientFeather.angle", e.angle)?; - e.start = opacity("gradientFeather.start", e.start)?; - e.end = opacity("gradientFeather.end", e.end)?; - if let Some(v) = &mut e.vector { - for c in v.iter_mut() { - *c = finite("gradientFeather.vector", *c)?; - } - } - let e = &mut self.inner_glow; - e.opacity = opacity("innerGlow.opacity", e.opacity)?; - e.size = len("innerGlow.size", e.size)?; - e.choke = pct("innerGlow.choke", e.choke)?; - let e = &mut self.bevel; - e.size = len("bevel.size", e.size)?; - e.depth = len("bevel.depth", e.depth)?; - e.angle = finite("bevel.angle", e.angle)?; - e.highlight_opacity = opacity("bevel.highlightOpacity", e.highlight_opacity)?; - e.shadow_opacity = opacity("bevel.shadowOpacity", e.shadow_opacity)?; - let e = &mut self.satin; - e.opacity = opacity("satin.opacity", e.opacity)?; - e.angle = finite("satin.angle", e.angle)?; - e.distance = len("satin.distance", e.distance)?; - e.size = len("satin.size", e.size)?; - for w in self.directional_feather.widths.iter_mut() { - *w = len("directionalFeather.widths", *w)?; - } - Ok(self) - } - - /// Any effect drawn with soft (raster) filters? - pub fn any(&self) -> bool { - self.drop_shadow.on - || self.feather > 0.0 - || self.inner_shadow.on - || self.outer_glow.on - || self.gradient_feather.on - || self.inner_glow.on - || self.bevel.on - || self.satin.on - || self.directional_feather.on - } -} - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Item { diff --git a/crates/doc/src/item/content.rs b/crates/doc/src/item/content.rs new file mode 100644 index 0000000000..968cd4aca9 --- /dev/null +++ b/crates/doc/src/item/content.rs @@ -0,0 +1,28 @@ +use super::graphic::Graphic; +use super::text_frame::TextFrame; +use super::*; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum Content { + #[default] + Unassigned, + Text(TextFrame), + Graphic(Graphic), + Group { + items: Vec>, + }, +} + +/// What kind of spline the item was drawn as (labels in the Layers panel, tool behaviours). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Shape { + #[default] + Rectangle, + Oval, + Polygon, + GraphicLine, + Path, + Group, +} diff --git a/crates/doc/src/item/effects.rs b/crates/doc/src/item/effects.rs new file mode 100644 index 0000000000..e8e920554e --- /dev/null +++ b/crates/doc/src/item/effects.rs @@ -0,0 +1,345 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DropShadow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + /// Use Global Light (the document angle instead of `angle`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub global_light: bool, + pub distance: f64, + pub size: f64, + pub spread: f64, +} + +impl Default for DropShadow { + fn default() -> Self { + DropShadow { + on: false, + color: designcraft_color::swatch::BLACK.into(), + opacity: 0.75, + angle: 120.0, + distance: 7.0, + size: 5.0, + spread: 0.0, + global_light: false, + } + } +} + +/// Inner shadow: a blurred, offset shadow inside the object's edge. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InnerShadow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + /// Use Global Light (the document angle instead of `angle`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub global_light: bool, + pub distance: f64, + pub size: f64, + /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. + pub choke: f64, +} + +impl Default for InnerShadow { + fn default() -> Self { + InnerShadow { + on: false, + color: designcraft_color::swatch::BLACK.into(), + opacity: 0.75, + angle: 120.0, + distance: 7.0, + size: 7.0, + choke: 0.0, + global_light: false, + } + } +} + +/// Outer glow: a blurred silhouette around the object, without offset. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OuterGlow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub size: f64, + /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. + pub spread: f64, +} + +impl Default for OuterGlow { + fn default() -> Self { + OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Effects { + /// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows + /// the silhouette instead of blurring it. + pub drop_shadow: DropShadow, + /// Basic feather width (0 = off). + pub feather: f64, + /// Basic feather's choke and corners. + #[serde(skip_serializing_if = "is_default")] + pub feather_options: FeatherOptions, + #[serde(skip_serializing_if = "is_default")] + pub inner_shadow: InnerShadow, + #[serde(skip_serializing_if = "is_default")] + pub outer_glow: OuterGlow, + #[serde(skip_serializing_if = "is_default")] + pub gradient_feather: GradientFeather, + #[serde(skip_serializing_if = "is_default")] + pub inner_glow: InnerGlow, + #[serde(skip_serializing_if = "is_default")] + pub bevel: Bevel, + #[serde(skip_serializing_if = "is_default")] + pub satin: Satin, + #[serde(skip_serializing_if = "is_default")] + pub directional_feather: DirectionalFeather, +} + +/// Basic Feather › Corners: how the fade follows the shape's corners. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FeatherCorners { + /// Mitred: the fade keeps the corners sharp. + Sharp, + /// The fade rounds the corners by its width. + Rounded, + /// From opaque to transparent, softly (InDesign's default). + #[default] + Diffused, +} + +/// Basic Feather options besides its width. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FeatherOptions { + /// Percentage (0–100) of the width that stays opaque: the fade gets shorter. + pub choke: f64, + pub corners: FeatherCorners, +} + +/// Inner/Outer Glow › Technique. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GlowTechnique { + /// A soft, blurred glow. + #[default] + Softer, + /// A glow that keeps closer to the shape's edges. + Precise, +} + +/// Directional Feather: each side fades over its own width (item space, before rotation). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DirectionalFeather { + pub on: bool, + /// Top, left, bottom, right widths in points. + pub widths: [f64; 4], +} + +/// Inner Glow: a glow inside the edge (or from the centre). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InnerGlow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub size: f64, + /// Percentage (0–100) of `size` that hardens the glow. + pub choke: f64, + /// Glow from the centre instead of the edges. + pub center: bool, + /// How the glow blends with the object (InDesign: Screen). + pub mode: BlendMode, + pub technique: GlowTechnique, +} + +impl Default for InnerGlow { + fn default() -> Self { + InnerGlow { + on: false, + color: designcraft_color::swatch::PAPER.into(), + opacity: 0.75, + size: 7.0, + choke: 0.0, + center: false, + mode: BlendMode::Screen, + technique: GlowTechnique::Softer, + } + } +} + +/// Bevel and Emboss (inner bevel): a highlight on the lit edges, a shadow on the others. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Bevel { + pub on: bool, + pub size: f64, + /// Depth in percent (scales the highlight and shadow offset). + pub depth: f64, + pub angle: f64, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub global_light: bool, + pub highlight: String, + pub highlight_opacity: f32, + pub shadow: String, + pub shadow_opacity: f32, +} + +impl Default for Bevel { + fn default() -> Self { + Bevel { + on: false, + size: 7.0, + depth: 100.0, + angle: 120.0, + global_light: true, + highlight: designcraft_color::swatch::PAPER.into(), + highlight_opacity: 0.75, + shadow: designcraft_color::swatch::BLACK.into(), + shadow_opacity: 0.75, + } + } +} + +/// Satin: soft interior shading from two offset copies of the shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Satin { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + pub distance: f64, + pub size: f64, + pub invert: bool, +} + +impl Default for Satin { + fn default() -> Self { + Satin { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.5, angle: 120.0, distance: 7.0, size: 7.0, invert: false } + } +} + +/// Gradient Feather: the object fades from `start` to `end` opacity along a gradient. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GradientFeather { + pub on: bool, + pub radial: bool, + /// Degrees (linear), when no vector was dragged. + pub angle: f64, + pub start: f32, + pub end: f32, + /// Gradient Feather tool vector in the item's space: start and end (linear), centre and + /// radius point (radial). + #[serde(skip_serializing_if = "Option::is_none")] + pub vector: Option<[f64; 4]>, +} + +impl Default for GradientFeather { + fn default() -> Self { + GradientFeather { on: false, radial: false, angle: 0.0, start: 1.0, end: 0.0, vector: None } + } +} + +impl GradientFeather { + /// The gradient's two points in item space for an object with inner bounds `b`. + pub fn points(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, designcraft_geom::Point) { + use designcraft_geom::{Point, Vec2}; + if let Some([x0, y0, x1, y1]) = self.vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let c = b.center(); + if self.radial { + return (c, c + Vec2::new(b.width().max(b.height()) / 2.0, 0.0)); + } + let a = self.angle.to_radians(); + let d = Vec2::new(a.cos(), -a.sin()); + let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; + (c - d * half, c + d * half) + } +} + +impl Effects { + /// These effects with opacities in 0–1, percentages in 0–100 and sizes, widths and distances + /// not below 0, or an error naming the first value that isn't a finite number. + pub fn sanitized(mut self) -> Result { + fn finite(name: &str, v: f64) -> Result { + if v.is_finite() { Ok(v) } else { Err(format!("{name} must be a finite number")) } + } + let len = |name: &str, v: f64| finite(name, v).map(|v| v.max(0.0)); + let pct = |name: &str, v: f64| finite(name, v).map(|v| v.clamp(0.0, 100.0)); + let opacity = |name: &str, v: f32| finite(name, f64::from(v)).map(|v| v.clamp(0.0, 1.0) as f32); + let ds = &mut self.drop_shadow; + ds.opacity = opacity("dropShadow.opacity", ds.opacity)?; + ds.angle = finite("dropShadow.angle", ds.angle)?; + ds.distance = len("dropShadow.distance", ds.distance)?; + ds.size = len("dropShadow.size", ds.size)?; + ds.spread = pct("dropShadow.spread", ds.spread)?; + self.feather = len("feather", self.feather)?; + let e = &mut self.inner_shadow; + e.opacity = opacity("innerShadow.opacity", e.opacity)?; + e.angle = finite("innerShadow.angle", e.angle)?; + e.distance = len("innerShadow.distance", e.distance)?; + e.size = len("innerShadow.size", e.size)?; + e.choke = pct("innerShadow.choke", e.choke)?; + let e = &mut self.outer_glow; + e.opacity = opacity("outerGlow.opacity", e.opacity)?; + e.size = len("outerGlow.size", e.size)?; + e.spread = pct("outerGlow.spread", e.spread)?; + let e = &mut self.gradient_feather; + e.angle = finite("gradientFeather.angle", e.angle)?; + e.start = opacity("gradientFeather.start", e.start)?; + e.end = opacity("gradientFeather.end", e.end)?; + if let Some(v) = &mut e.vector { + for c in v.iter_mut() { + *c = finite("gradientFeather.vector", *c)?; + } + } + let e = &mut self.inner_glow; + e.opacity = opacity("innerGlow.opacity", e.opacity)?; + e.size = len("innerGlow.size", e.size)?; + e.choke = pct("innerGlow.choke", e.choke)?; + let e = &mut self.bevel; + e.size = len("bevel.size", e.size)?; + e.depth = len("bevel.depth", e.depth)?; + e.angle = finite("bevel.angle", e.angle)?; + e.highlight_opacity = opacity("bevel.highlightOpacity", e.highlight_opacity)?; + e.shadow_opacity = opacity("bevel.shadowOpacity", e.shadow_opacity)?; + let e = &mut self.satin; + e.opacity = opacity("satin.opacity", e.opacity)?; + e.angle = finite("satin.angle", e.angle)?; + e.distance = len("satin.distance", e.distance)?; + e.size = len("satin.size", e.size)?; + for w in self.directional_feather.widths.iter_mut() { + *w = len("directionalFeather.widths", *w)?; + } + Ok(self) + } + + /// Any effect drawn with soft (raster) filters? + pub fn any(&self) -> bool { + self.drop_shadow.on + || self.feather > 0.0 + || self.inner_shadow.on + || self.outer_glow.on + || self.gradient_feather.on + || self.inner_glow.on + || self.bevel.on + || self.satin.on + || self.directional_feather.on + } +} diff --git a/crates/doc/src/item/graphic.rs b/crates/doc/src/item/graphic.rs new file mode 100644 index 0000000000..4c02ce949d --- /dev/null +++ b/crates/doc/src/item/graphic.rs @@ -0,0 +1,367 @@ +use super::content::Content; +use super::paint::Fill; +use super::text_frame::TextWrap; +use super::*; + +/// Frame fitting options (Object → Fitting → Frame Fitting Options). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Fitting { + #[default] + None, + FillProportionally, + FitProportionally, + FitContentToFrame, + CenterContent, +} + +/// Retained IDML layer choices for one placement, independent of shared asset bytes. +/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy. +/// XML Self IDs are regenerated by the exporter and are not stored here. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphicLayerOptions { + pub attributes: std::collections::BTreeMap, + pub layers: Vec>, + /// Parent indices in pre-order; absent entries are top-level layers (older native files). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parents: Vec>, +} + +pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = + "Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output"; + +impl GraphicLayerOptions { + pub fn has_visibility_overrides(&self) -> bool { + self.layers.iter().any(|layer| { + layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility")) + || ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true")) + }) + } +} + +/// A placed graphic inside a frame. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Graphic { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_options: Option, + pub asset: AssetId, + /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). + pub size: (f64, f64), + /// Graphic space (0,0)-(w,h) → frame inner space. + pub xf: Affine, + /// Clipping path: only the part of the graphic inside it shows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clip: Option>, + #[serde(default)] + pub auto_fit: Fitting, + /// Frame Fitting Options › Align From: the reference point (0–8, row-major; 4 = centre). + #[serde(default = "center_ref")] + pub fit_align: u8, + /// Frame Fitting Options › Crop Amount: top, left, bottom, right (negative adds space). + #[serde(default)] + pub crop: [f64; 4], + /// Text wrap set on the graphic itself (Direct Selection), around the part its frame shows. + #[serde(default, skip_serializing_if = "TextWrap::is_default")] + pub wrap: TextWrap, + /// The swatch applied to the image itself: InDesign colours a greyscale or bitmap image with + /// it, colour images ignore it. `None` (the default, like InDesign's Black) leaves it as is. + #[serde(default, skip_serializing_if = "Fill::is_none")] + pub fill: Fill, +} + +fn center_ref() -> u8 { + 4 +} + +/// Most anchors a clipping path read from a file keeps (detected edges can be dense). +pub const MAX_CLIP_ANCHORS: usize = 100_000; + +/// Clipping Path › Type: where the clipping path came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ClippingKind { + /// Traced from the image's edges against a light background. + DetectEdges, + /// Traced from one of the image's alpha channels. + AlphaChannel, + /// A path saved in the image file. + PhotoshopPath, + /// One of the above, edited by hand. + UserModifiedPath, +} + +/// A placed graphic's clipping path. The tracing options are kept as the file had them; the +/// outline itself is `path`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClippingPath { + pub kind: ClippingKind, + /// The outline in graphic space ((0,0)-(w,h) is the graphic's natural rectangle). + pub path: PathData, + /// Show the graphic outside the path instead of inside it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub invert: bool, + /// Holes inside the traced shape are part of the path. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_inside_edges: bool, + /// Detect Edges: trace only the part of the image the frame shows. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub restrict_to_frame: bool, + /// Trace the full-resolution image rather than the screen proxy. + #[serde(default = "yes")] + pub use_high_resolution: bool, + /// Detect Edges / Alpha Channel: the darkness or opacity level that counts as inside (0–255). + #[serde(default = "clip_threshold")] + pub threshold: f64, + /// How closely the traced path follows the pixels, in pixels. + #[serde(default = "clip_tolerance")] + pub tolerance: f64, + /// Inset Frame: how far the path is pulled in (points; negative pushes it out). + #[serde(default)] + pub inset: f64, + /// The alpha channel or image path the clip came from. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_name: String, + /// Index of the alpha channel (-1 = none). + #[serde(default = "no_index")] + pub source_index: i32, +} + +fn yes() -> bool { + true +} + +fn clip_threshold() -> f64 { + 25.0 +} + +fn clip_tolerance() -> f64 { + 2.0 +} + +fn no_index() -> i32 { + -1 +} + +impl ClippingPath { + /// A clipping path of `kind` with the default options. + pub fn new(kind: ClippingKind, path: PathData) -> Self { + Self { + kind, + path, + invert: false, + include_inside_edges: false, + restrict_to_frame: false, + use_high_resolution: true, + threshold: clip_threshold(), + tolerance: clip_tolerance(), + inset: 0.0, + source_name: String::new(), + source_index: no_index(), + } + } +} + +/// Liquid Layout object rules: which edges stay at their distance from the page edge, and +/// whether the object may stretch. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ObjectLiquid { + pub resize_width: bool, + pub resize_height: bool, + pub pin_top: bool, + pub pin_bottom: bool, + pub pin_left: bool, + pub pin_right: bool, +} + +/// Object Export Options: tagged PDF and EPUB/HTML handling of one object. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ExportOptions { + /// Tagged PDF: the object is decoration (an artifact), not content. + pub artifact: bool, + /// EPUB/HTML: export the object as an image (whatever it is). + pub rasterize: bool, + /// EPUB/HTML: "left", "center" or "right" ("" = as the flow puts it). + pub align: String, + /// EPUB/HTML: start a new page (screen) before the object. + pub page_break_before: bool, +} + +impl ExportOptions { + pub fn is_default(&self) -> bool { + *self == ExportOptions::default() + } +} + +/// A PDF form field. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FormField { + pub kind: FieldKind, + /// Field name (unique in the exported PDF). + pub name: String, + /// Default value: the text, the chosen option, or "On"/"" for a check box. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub value: String, + /// Choices of a combo box or list box. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub multiline: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub required: bool, + /// Font size of the field's text (0 = auto). + #[serde(default)] + pub font_size: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FieldKind { + TextField, + CheckBox, + ComboBox, + ListBox, + Signature, +} + +/// The source and template of a live caption. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveCaption { + pub source: ItemId, + pub template: String, +} + +/// Media panel options for a placed video or sound. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct MediaOptions { + pub play_on_page_load: bool, + #[serde(rename = "loop")] + pub looping: bool, + /// Show the player controls. + pub controls: bool, + /// Poster image (shown on the page and in print); `None` = a placeholder frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub poster: Option, +} + +impl Item { + /// The graphic to draw for this frame: a media frame with a poster draws the poster + /// stretched over the media rectangle. + pub fn drawn_graphic(&self, assets: &std::collections::BTreeMap>) -> Option { + let Content::Graphic(g) = &self.content else { return None }; + if let Some(poster) = self.media.as_ref().and_then(|m| m.poster) + && let Some((pw, ph)) = assets.get(&poster).and_then(|a| a.pixels) + { + let (pw, ph) = (pw.max(1) as f64, ph.max(1) as f64); + return Some(Graphic { asset: poster, size: (pw, ph), xf: g.xf * Affine::scale_non_uniform(g.size.0 / pw, g.size.1 / ph), ..g.clone() }); + } + Some(g.clone()) + } +} + +/// "video" or "audio" for a media MIME type. +pub fn media_kind(mime: &str) -> Option<&'static str> { + if mime.starts_with("video/") { + Some("video") + } else if mime.starts_with("audio/") { + Some("audio") + } else { + None + } +} + +/// MIME type of a media file by its extension. +pub fn media_mime(name: &str) -> Option<&'static str> { + let ext = name.rsplit('.').next()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "mp4" | "m4v" => "video/mp4", + "mov" => "video/quicktime", + "webm" => "video/webm", + "ogv" => "video/ogg", + "mp3" => "audio/mpeg", + "m4a" => "audio/mp4", + "wav" => "audio/wav", + "ogg" | "oga" => "audio/ogg", + "aac" => "audio/aac", + _ => return None, + }) +} + +/// A button's On Release action. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ButtonAction { + GoToPage { page: usize }, + GoToFirstPage, + GoToLastPage, + GoToNextPage, + GoToPreviousPage, + GoToUrl { url: String }, +} + +impl Graphic { + /// The part of the graphic its frame shows, in the frame's inner space (bounding boxes); + /// None when nothing shows. + pub fn shown_bounds(&self, frame: Rect) -> Option { + let r = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, self.size.0, self.size.1)).intersect(frame); + (r.width() > 0.0 && r.height() > 0.0).then_some(r) + } + + /// The region the clipping path lets through, in frame inner space, and whether it is filled + /// even-odd (an inverted clip: the graphic's rectangle minus the path). `None` when the graphic + /// has no clipping path or the path is empty. + pub fn clip_region(&self) -> Option<(designcraft_geom::BezPath, bool)> { + use designcraft_geom::Shape; + let c = self.clip.as_ref().filter(|c| !c.path.is_empty())?; + let mut path = c.path.to_bezpath(); + if !c.invert { + path.apply_affine(self.xf); + return Some((path, false)); + } + let outer = Rect::new(0.0, 0.0, self.size.0, self.size.1).union(path.bounding_box()).inflate(1.0, 1.0); + let mut region = outer.to_path(0.1); + region.extend(path.iter()); + region.apply_affine(self.xf); + Some((region, true)) + } + + /// The graphic transform that fits it into `frame` (the frame's inner rect) with `mode`, + /// honouring the crop amounts and the reference point. + pub fn fitted(&self, frame: Rect, mode: Fitting) -> Option { + let (nw, nh) = self.size; + if nw <= 0.0 || nh <= 0.0 { + return None; + } + let [t, l, b, r] = self.crop; + let area = Rect::new(frame.x0 - l, frame.y0 - t, frame.x1 + r, frame.y1 + b); + if area.width() <= 0.0 || area.height() <= 0.0 { + return None; + } + let (fx, fy) = ([0.0, 0.5, 1.0][(self.fit_align % 3) as usize], [0.0, 0.5, 1.0][(self.fit_align / 3).min(2) as usize]); + let place = |w: f64, h: f64| (area.x0 + (area.width() - w) * fx, area.y0 + (area.height() - h) * fy); + Some(match mode { + Fitting::None => return None, + Fitting::FillProportionally | Fitting::FitProportionally => { + let k = if mode == Fitting::FillProportionally { + (area.width() / nw).max(area.height() / nh) + } else { + (area.width() / nw).min(area.height() / nh) + }; + Affine::translate(place(nw * k, nh * k)) * Affine::scale(k) + } + Fitting::FitContentToFrame => Affine::translate((area.x0, area.y0)) * Affine::scale_non_uniform(area.width() / nw, area.height() / nh), + Fitting::CenterContent => { + let cur = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); + let (x, y) = place(cur.width(), cur.height()); + Affine::translate((x - cur.x0, y - cur.y0)) * self.xf + } + }) + } +} diff --git a/crates/doc/src/item/paint.rs b/crates/doc/src/item/paint.rs new file mode 100644 index 0000000000..04d8323fcc --- /dev/null +++ b/crates/doc/src/item/paint.rs @@ -0,0 +1,357 @@ +use super::*; + +/// Fill: a swatch reference and tint (InDesign fills always go through swatches or unnamed colours). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Fill { + pub swatch: String, + pub tint: f32, + /// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds). + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + /// Gradient Swatch tool vector in the item's own space: start and end of a linear gradient, + /// centre and radius point of a radial one (`None` = fit to bounds at `gradient_angle`). + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, + pub overprint: bool, +} + +impl Default for Fill { + fn default() -> Self { + Fill::none() + } +} + +impl Fill { + /// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and + /// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's + /// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box); + /// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's + /// length, measured with the box made square, sets its size. + pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) { + let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 }; + match self.gradient_vector { + Some([x0, y0, x1, y1]) => { + let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot(); + (designcraft_geom::Point::new(x0, y0), r, r * k) + } + // A box with no width or height (a straight line's) has no ellipse: a circle across + // its length. + None if b.width() <= 1e-9 || b.height() <= 1e-9 => { + let r = b.width().max(b.height()).max(1e-3) / 2.0; + (b.center(), r, r) + } + None => (b.center(), b.width() / 2.0, b.height() / 2.0), + } + } + + /// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the + /// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its + /// centre. A box with no extent along that angle (a straight line, across it) takes its longer + /// side. + pub fn linear_axis(&self, b: Rect) -> (Point, Point) { + if let Some([x0, y0, x1, y1]) = self.gradient_vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let a = self.gradient_angle.unwrap_or(0.0).to_radians(); + let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0; + let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 }; + let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half; + (b.center() - d, b.center() + d) + } + + pub fn none() -> Self { + Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } + } + pub fn swatch(name: &str) -> Self { + Fill { swatch: name.into(), ..Fill::none() } + } + pub fn is_none(&self) -> bool { + self.swatch == designcraft_color::swatch::NONE + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StrokeAlign { + #[default] + Center, + Inside, + Outside, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Cap { + #[default] + Butt, + Round, + Projecting, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Join { + #[default] + Miter, + Round, + Bevel, +} + +/// The shortest non-zero dash or gap a command accepts, in points. +pub const MIN_DASH: f64 = 0.1; + +/// The longest dash or gap a command accepts, in points. +pub const MAX_DASH: f64 = 10_000.0; + +/// The most lengths a dash pattern holds. +pub const MAX_DASH_ENTRIES: usize = 64; + +/// The most pattern repeats a renderer expands along one path; finer patterns draw solid. +pub const MAX_DASH_PERIODS: f64 = 20_000.0; + +/// An upper bound of the length of `bp`: the length of its control polygon. +pub fn path_length_bound(bp: &designcraft_geom::BezPath) -> f64 { + use designcraft_geom::PathSeg; + bp.segments() + .map(|s| match s { + PathSeg::Line(l) => l.p0.distance(l.p1), + PathSeg::Quad(q) => q.p0.distance(q.p1) + q.p1.distance(q.p2), + PathSeg::Cubic(c) => c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3), + }) + .sum() +} + +/// Stroke types (Stroke panel → Type). Dash/gap arrays are in points. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum StrokeType { + #[default] + Solid, + Dashed { + pattern: Vec, + }, + Dotted, + ThickThin, + ThinThick, + ThinThin, + ThickThick, + ThinThickThin, + ThickThinThick, + Wavy, + Hashed, + /// A custom striped stroke: bands (start, width) as fractions of the weight from the left edge. + Stripes { + bands: Vec<(f64, f64)>, + }, + /// A named stroke style of the document (Stroke Styles), by name. + Style { + name: String, + }, +} + +impl StrokeType { + pub fn label(&self) -> &'static str { + match self { + StrokeType::Solid => "Solid", + StrokeType::Dashed { .. } => "Dashed", + StrokeType::Dotted => "Dotted", + StrokeType::ThickThin => "Thick - Thin", + StrokeType::ThinThick => "Thin - Thick", + StrokeType::ThinThin => "Thin - Thin", + StrokeType::ThickThick => "Thick - Thick", + StrokeType::ThinThickThin => "Thin - Thick - Thin", + StrokeType::ThickThinThick => "Thick - Thin - Thick", + StrokeType::Wavy => "Wavy", + StrokeType::Hashed => "Straight Hash", + StrokeType::Stripes { .. } => "Stripes", + StrokeType::Style { .. } => "Custom", + } + } + + /// Whether `pattern` is a dash pattern commands accept: 1 to [`MAX_DASH_ENTRIES`] lengths, + /// each 0 or from [`MIN_DASH`] to [`MAX_DASH`] points, not all 0. + pub fn valid_dash_pattern(pattern: &[f64]) -> bool { + !pattern.is_empty() + && pattern.len() <= MAX_DASH_ENTRIES + && pattern.iter().all(|x| *x == 0.0 || (MIN_DASH..=MAX_DASH).contains(x)) + && pattern.iter().any(|x| *x > 0.0) + } + + /// Whether a renderer may expand `pattern` along a path at most `length` points long: at + /// most [`MAX_DASH_ENTRIES`] finite, non-negative lengths that repeat at most + /// [`MAX_DASH_PERIODS`] times. Otherwise the stroke draws solid, so a degenerate pattern + /// from a file can't stall rendering. + pub fn expandable_dashes(pattern: &[f64], length: f64) -> bool { + let period: f64 = pattern.iter().sum(); + pattern.len() <= MAX_DASH_ENTRIES + && pattern.iter().all(|x| x.is_finite() && *x >= 0.0) + && period > 0.0 + && length.is_finite() + && length / period <= MAX_DASH_PERIODS + } + + /// The bands of a striped type (start, width as fractions of the weight). + pub fn bands(&self) -> Option> { + Some(match self { + StrokeType::ThickThin => vec![(0.0, 0.5), (0.75, 0.25)], + StrokeType::ThinThick => vec![(0.0, 0.25), (0.5, 0.5)], + StrokeType::ThinThin => vec![(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0 / 3.0)], + StrokeType::ThickThick => vec![(0.0, 0.4), (0.6, 0.4)], + StrokeType::ThinThickThin => vec![(0.0, 0.2), (0.35, 0.3), (0.8, 0.2)], + StrokeType::ThickThinThick => vec![(0.0, 0.35), (0.45, 0.1), (0.65, 0.35)], + StrokeType::Stripes { bands } => bands.clone(), + _ => return None, + }) + } + + /// The filled area for types drawn as fills (stripes, wavy, hash); `None` for solid, dashed + /// and dotted strokes (and unresolved named styles). + pub fn outline(&self, bp: &designcraft_geom::BezPath, weight: f64, tol: f64) -> Option { + use designcraft_geom::stroke_style as ss; + match self { + StrokeType::Wavy => Some(ss::wavy(bp, weight, tol)), + StrokeType::Hashed => Some(ss::hashed(bp, weight, tol)), + t => t.bands().map(|b| ss::stripes(bp, weight, &b, tol)), + } + } +} + +/// A named custom stroke style (Stroke Styles dialog): dashes, dots or stripes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StrokeStyleDef { + pub name: String, + pub kind: StrokeType, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Arrowhead { + #[default] + None, + SimpleWide, + Simple, + Triangle, + TriangleWide, + Barbed, + Curved, + Circle, + CircleSolid, + Square, + SquareSolid, + Bar, +} + +impl Arrowhead { + pub const ALL: [Arrowhead; 12] = [ + Arrowhead::None, + Arrowhead::Simple, + Arrowhead::SimpleWide, + Arrowhead::Triangle, + Arrowhead::TriangleWide, + Arrowhead::Barbed, + Arrowhead::Curved, + Arrowhead::Circle, + Arrowhead::CircleSolid, + Arrowhead::Square, + Arrowhead::SquareSolid, + Arrowhead::Bar, + ]; + + pub fn label(self) -> &'static str { + match self { + Arrowhead::None => "None", + Arrowhead::Simple => "Simple", + Arrowhead::SimpleWide => "Simple - Wide", + Arrowhead::Triangle => "Triangle", + Arrowhead::TriangleWide => "Triangle - Wide", + Arrowhead::Barbed => "Barbed", + Arrowhead::Curved => "Curved", + Arrowhead::Circle => "Circle", + Arrowhead::CircleSolid => "Circle - Solid", + Arrowhead::Square => "Square", + Arrowhead::SquareSolid => "Square - Solid", + Arrowhead::Bar => "Bar", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Stroke { + pub swatch: String, + pub tint: f32, + pub weight: f64, + #[serde(rename = "type")] + pub kind: StrokeType, + pub align: StrokeAlign, + pub cap: Cap, + pub join: Join, + pub miter_limit: f64, + pub start: Arrowhead, + pub end: Arrowhead, + /// Gap colour for dashed/striped strokes. + pub gap_swatch: String, + pub gap_tint: f32, + pub overprint: bool, + /// Overprint Gap (Attributes panel). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub gap_overprint: bool, + /// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + /// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, +} + +impl Default for Stroke { + /// InDesign's default for drawn shapes: 1 pt black. + fn default() -> Self { + Stroke { + swatch: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + weight: 1.0, + kind: StrokeType::Solid, + align: StrokeAlign::Center, + cap: Cap::Butt, + join: Join::Miter, + miter_limit: 4.0, + start: Arrowhead::None, + end: Arrowhead::None, + gap_swatch: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + overprint: false, + gap_overprint: false, + gradient_angle: None, + gradient_vector: None, + } + } +} + +impl Stroke { + /// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way: + /// a gradient swatch on a stroke runs across the object's box as on its fill. + pub fn as_fill(&self) -> Fill { + Fill { + swatch: self.swatch.clone(), + tint: self.tint, + gradient_angle: self.gradient_angle, + gradient_vector: self.gradient_vector, + overprint: self.overprint, + } + } + + /// How far the painted stroke can reach beyond the path (arrowheads reach further). + pub fn extent(&self) -> f64 { + if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 } + } + + pub fn none() -> Self { + Stroke { swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() } + } + pub fn is_none(&self) -> bool { + self.swatch == designcraft_color::swatch::NONE || self.weight <= 0.0 + } +} diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs new file mode 100644 index 0000000000..dc501832cb --- /dev/null +++ b/crates/doc/src/item/text_frame.rs @@ -0,0 +1,321 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VerticalJustification { + #[default] + Top, + Center, + Bottom, + Justify, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FirstBaseline { + #[default] + Ascent, + CapHeight, + Leading, + XHeight, + Fixed, + /// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo + /// ascender when the typo ascender and descender span one em; else half an em above half the + /// cap height. + EmboxHeight, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutoSize { + #[default] + Off, + HeightOnly, + WidthOnly, + HeightAndWidth, + HeightAndWidthProportionally, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColumnsKind { + #[default] + FixedNumber, + FixedWidth, + FlexibleWidth, +} + +/// Text Frame Options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TextFrameOptions { + pub columns: u32, + pub columns_kind: ColumnsKind, + pub gutter: f64, + /// Column width for fixed/flexible width columns. + pub column_width: f64, + pub balance_columns: bool, + /// Inset spacing: top, left, bottom, right. + pub inset: [f64; 4], + pub vertical_justification: VerticalJustification, + pub vj_paragraph_spacing_limit: f64, + pub first_baseline: FirstBaseline, + pub first_baseline_min: f64, + pub ignore_wrap: bool, + pub auto_size: AutoSize, + /// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing. + pub auto_size_ref: u8, + /// Auto-size never makes the frame shorter than `auto_size_min_height` while this is on. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_use_min_height: bool, + #[serde(skip_serializing_if = "is_default")] + pub auto_size_min_height: f64, + /// Auto-size never makes the frame narrower than `auto_size_min_width` while this is on. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_use_min_width: bool, + #[serde(skip_serializing_if = "is_default")] + pub auto_size_min_width: f64, + /// Width-growing auto-size sets each paragraph on one line (the frame widens to the longest). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_no_line_breaks: bool, + pub column_rule: bool, + pub column_rule_weight: f64, + pub column_rule_color: String, + /// 0..1. + pub column_rule_tint: f32, + /// Horizontal offset of each rule from the middle of its gutter. + pub column_rule_offset: f64, + /// The rule starts this far below the column top and ends this far above its bottom. + pub column_rule_top_inset: f64, + pub column_rule_bottom_inset: f64, + /// Use a custom baseline grid for this frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_grid: Option, + /// Type on a Path: the text runs along the item's path instead of filling it. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the +/// top of the page, otherwise as `[start, increment, relativeTo]`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameBaselineGrid { + /// The first grid line, down from the line `relative_to` names. + pub start: f64, + /// The distance between grid lines; 0 or less turns the grid off. + pub increment: f64, + pub relative_to: FrameGridRelative, +} + +impl FrameBaselineGrid { + /// A grid measured from the top of the page. + pub fn new(start: f64, increment: f64) -> Self { + FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage } + } +} + +/// What a frame's custom baseline grid's start is measured from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrameGridRelative { + #[default] + #[serde(rename = "page")] + TopOfPage, + #[serde(rename = "margin")] + TopMargin, + #[serde(rename = "frame")] + TopOfFrame, + #[serde(rename = "inset")] + TopInset, +} + +impl FrameGridRelative { + pub const ALL: [FrameGridRelative; 4] = + [FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset]; + + /// The name commands and native files use: page, margin, frame or inset. + pub fn name(self) -> &'static str { + match self { + FrameGridRelative::TopOfPage => "page", + FrameGridRelative::TopMargin => "margin", + FrameGridRelative::TopOfFrame => "frame", + FrameGridRelative::TopInset => "inset", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|r| r.name() == name) + } +} + +impl Serialize for FrameBaselineGrid { + fn serialize(&self, s: S) -> Result { + // Files saved before Relative To existed hold two elements; a page-relative grid keeps that form. + match self.relative_to { + FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s), + r => (self.start, self.increment, r).serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for FrameBaselineGrid { + fn deserialize>(d: D) -> Result { + struct Grid; + impl<'de> serde::de::Visitor<'de> for Grid { + type Value = FrameBaselineGrid; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("[start, increment] or [start, increment, relativeTo]") + } + fn visit_seq>(self, mut a: A) -> Result { + use serde::de::Error; + let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; + let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; + let relative_to = a.next_element()?.unwrap_or_default(); + if a.next_element::()?.is_some() { + return Err(A::Error::invalid_length(4, &self)); + } + Ok(FrameBaselineGrid { start, increment, relative_to }) + } + } + d.deserialize_seq(Grid) + } +} + +/// Type on a Path Options. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct PathType { + /// Where the text starts, as a distance along the path (the start bracket). + pub start: f64, + /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's + /// end. Text past it is overset. + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + /// Run the other way (the text sits on the other side). + pub flip: bool, + pub align: PathAlign, +} + +/// Which part of the type sits on the path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathAlign { + #[default] + Baseline, + Ascender, + Descender, + Center, +} + +impl Default for TextFrameOptions { + fn default() -> Self { + TextFrameOptions { + columns: 1, + columns_kind: ColumnsKind::FixedNumber, + gutter: 12.0, + column_width: 0.0, + balance_columns: false, + inset: [0.0; 4], + vertical_justification: VerticalJustification::Top, + vj_paragraph_spacing_limit: 0.0, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + ignore_wrap: false, + auto_size: AutoSize::Off, + auto_size_ref: 4, + auto_size_use_min_height: false, + auto_size_min_height: 0.0, + auto_size_use_min_width: false, + auto_size_min_width: 0.0, + auto_size_no_line_breaks: false, + column_rule: false, + column_rule_weight: 1.0, + column_rule_color: designcraft_color::swatch::BLACK.into(), + column_rule_tint: 1.0, + column_rule_offset: 0.0, + column_rule_top_inset: 0.0, + column_rule_bottom_inset: 0.0, + baseline_grid: None, + path: None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WrapMode { + #[default] + None, + BoundingBox, + Contour, + JumpObject, + JumpToNextColumn, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WrapSide { + #[default] + BothSides, + RightSide, + LeftSide, + TowardsSpine, + AwayFromSpine, + LargestArea, +} + +/// Text Wrap panel › Contour Options › Type: the outline a contour wrap follows on a graphic frame. +/// Other frames always wrap around their own path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ContourType { + /// The placed graphic's rectangle (within the frame). + BoundingBox, + /// Edges detected in the image. Composition follows the frame path. + DetectEdges, + /// The image's alpha channel. Composition follows the frame path. + AlphaChannel, + /// A path saved in the image. Composition follows the frame path. + PhotoshopPath, + /// The frame's path. + GraphicFrame, + /// The graphic's clipping path, or the frame's path when it has none. + #[default] + SameAsClipping, +} + +/// Text Wrap panel settings. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TextWrap { + pub mode: WrapMode, + /// Top, left, bottom, right offsets. + pub offsets: [f64; 4], + pub invert: bool, + pub side: WrapSide, + pub contour: ContourType, + /// A custom wrap boundary in the item's inner space, offsets already included. Bounding-box + /// and contour wraps follow it instead of the frame; other modes use its bounds. Resizing the + /// item transforms it with the path; other path edits keep it as it is. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl TextWrap { + pub fn is_default(&self) -> bool { + *self == TextWrap::default() + } +} + +/// Vertical text: composed in a box `area.height()` wide and `area.width()` tall at the origin, +/// placed so lines go down and follow each other leftwards (a clockwise quarter turn). +pub fn vertical_text_xf(area: Rect) -> Affine { + Affine::new([0.0, 1.0, -1.0, 0.0, area.x1, area.y0]) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextFrame { + pub story: StoryId, + #[serde(default)] + pub options: TextFrameOptions, +} diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d80b1d4759..d5342b5c96 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -16,345 +16,14 @@ use crate::ids::StoryId; use crate::item::{FirstBaseline, StrokeType, VerticalJustification}; use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RowHeightMode { - /// Grows with the content (the height is a minimum). - #[default] - AtLeast, - Exactly, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RowKind { - Header, - #[default] - Body, - Footer, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Row { - pub height: f64, - pub mode: RowHeightMode, - pub kind: RowKind, -} - -impl Default for Row { - fn default() -> Self { - Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Column { - pub width: f64, -} - -/// One cell edge or the table border. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct CellStroke { - pub weight: f64, - pub color: String, - pub tint: f32, - pub kind: StrokeType, - /// Painted in the gaps of dashed, dotted and striped types. - #[serde(skip_serializing_if = "is_no_swatch")] - pub gap_color: String, - #[serde(skip_serializing_if = "is_full")] - pub gap_tint: f32, -} - -fn is_no_swatch(s: &str) -> bool { - s == designcraft_color::swatch::NONE -} -fn is_full(t: &f32) -> bool { - *t == 1.0 -} - -impl Default for CellStroke { - fn default() -> Self { - CellStroke { - weight: 1.0, - color: designcraft_color::swatch::BLACK.into(), - tint: 1.0, - kind: StrokeType::Solid, - gap_color: designcraft_color::swatch::NONE.into(), - gap_tint: 1.0, - } - } -} - -impl CellStroke { - pub fn none() -> Self { - CellStroke { weight: 0.0, ..Default::default() } - } - pub fn is_visible(&self) -> bool { - self.weight > 0.0 && self.color != designcraft_color::swatch::NONE - } -} - -/// Table-level alternating row/column strokes. Retained independently of cell formatting -/// so an explicit cell style or local edge continues to take precedence. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AltStrokes { - pub first: u32, - pub next: u32, - pub first_stroke: CellStroke, - pub next_stroke: CellStroke, - pub skip_first: u32, - pub skip_last: u32, -} - -impl Default for AltStrokes { - fn default() -> Self { - Self { - first: 0, - next: 0, - first_stroke: CellStroke::default(), - next_stroke: CellStroke { weight: 0.25, ..Default::default() }, - skip_first: 0, - skip_last: 0, - } - } -} - -impl AltStrokes { - /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal - /// patterns and skip settings are preserved for round trips until their boundary - /// phase is supported; they must not be rendered with a guessed phase. - pub fn uniform_stroke(&self) -> Option<&CellStroke> { - if self.skip_first > 0 || self.skip_last > 0 { - return None; - } - match (self.first > 0, self.next > 0) { - (true, false) => Some(&self.first_stroke), - (false, true) => Some(&self.next_stroke), - (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { - Some(&self.first_stroke) - } - _ => None, - } - } -} - -fn no_flags(flags: &[bool; 4]) -> bool { - !flags.iter().any(|v| *v) -} -fn no_priorities(priorities: &[i32; 4]) -> bool { - priorities.iter().all(|v| *v == 0) -} +mod cell; +mod edit; +mod grid; +mod merge; +mod story; -fn one() -> u32 { - 1 -} -fn full() -> f32 { - 1.0 -} -fn no_fill() -> String { - designcraft_color::swatch::NONE.into() -} -fn is_ascent(f: &FirstBaseline) -> bool { - *f == FirstBaseline::Ascent -} -fn is_zero_min(v: &f64) -> bool { - *v == 0.0 -} -fn default_insets() -> [f64; 4] { - [4.0; 4] -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Cell { - #[serde(default = "one")] - pub row_span: u32, - #[serde(default = "one")] - pub col_span: u32, - /// Cell content (a frameless story). - pub text: Story, - #[serde(default = "no_fill")] - pub fill: String, - #[serde(default = "full")] - pub fill_tint: f32, - /// Top, left, bottom, right. - #[serde(default = "default_insets")] - pub insets: [f64; 4], - #[serde(default)] - pub vj: VerticalJustification, - /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the - /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). - #[serde(default, skip_serializing_if = "is_ascent")] - pub first_baseline: FirstBaseline, - #[serde(default, skip_serializing_if = "is_zero_min")] - pub first_baseline_min: f64, - /// Text rotation in degrees (only 0 is composed for now). - #[serde(default)] - pub rotation: f64, - /// Edge strokes: top, left, bottom, right. - #[serde(default)] - pub strokes: [CellStroke; 4], - /// Explicit cell-edge formatting takes precedence over the table border, in the same - /// top, left, bottom, right order as `strokes`. Older documents keep their table border. - #[serde(default)] - pub border_overrides: [bool; 4], - /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. - #[serde(default, skip_serializing_if = "no_flags")] - pub stroke_defined: [bool; 4], - /// IDML edge-conflict priorities, in top, left, bottom, right order. - #[serde(default, skip_serializing_if = "no_priorities")] - pub stroke_priorities: [i32; 4], - /// Applied cell style ("" = [None]). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub style: String, - /// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's - /// content box, origin at the box's top left). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graphic: Option, -} - -impl Default for Cell { - fn default() -> Self { - Cell { - row_span: 1, - col_span: 1, - text: Story::new(StoryId(0)), - fill: no_fill(), - fill_tint: 1.0, - insets: default_insets(), - vj: VerticalJustification::Top, - first_baseline: FirstBaseline::Ascent, - first_baseline_min: 0.0, - rotation: 0.0, - strokes: Default::default(), - border_overrides: [false; 4], - stroke_priorities: [0; 4], - stroke_defined: [false; 4], - style: String::new(), - graphic: None, - } - } -} - -impl Cell { - pub fn with_text(text: &str, para: ParaFormat) -> Cell { - Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() } - } - /// A cell with this cell's formatting and no text (new rows / columns, unmerged cells). - pub fn blank_like(&self) -> Cell { - let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() }; - let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default(); - let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default(); - c.text = Story::new(StoryId(0)); - c.text.paras[0] = para; - c.text.chars[0].format = fmt; - c - } - pub fn has_fill(&self) -> bool { - self.fill != designcraft_color::swatch::NONE - } -} - -/// Table → Table Options → Fills (alternating pattern). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AltFills { - pub first: u32, - pub first_color: String, - pub first_tint: f32, - pub next: u32, - pub next_color: String, - pub next_tint: f32, - pub skip_first: u32, - pub skip_last: u32, -} - -impl Default for AltFills { - fn default() -> Self { - AltFills { - first: 1, - first_color: designcraft_color::swatch::BLACK.into(), - first_tint: 0.2, - next: 1, - next_color: designcraft_color::swatch::NONE.into(), - next_tint: 1.0, - skip_first: 0, - skip_last: 0, - } - } -} - -impl AltFills { - /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. - pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { - let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); - if i < sf || i >= n.saturating_sub(sl) { - return None; - } - let period = (u64::from(self.first) + u64::from(self.next)).max(1); - let k = (i - sf) as u64 % period; - let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; - (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct TableOptions { - /// Logical column zero is drawn at the right edge for RTL tables. - pub direction: crate::TextDirection, - /// Outer border (overrides the outer cell edges). - pub border: CellStroke, - /// Per-side borders; absent sides use the legacy uniform `border`. - #[serde(skip_serializing_if = "no_borders")] - pub borders: [Option; 4], - pub space_before: f64, - pub space_after: f64, - /// Header rows repeat at the top of every text column / frame the table continues into. - pub repeat_header: bool, - pub repeat_footer: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub alt_rows: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub alt_cols: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub row_strokes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub column_strokes: Option, -} - -fn no_borders(borders: &[Option; 4]) -> bool { - borders.iter().all(Option::is_none) -} - -impl TableOptions { - pub fn border_for(&self, side: usize) -> &CellStroke { - self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) - } -} - -impl Default for TableOptions { - fn default() -> Self { - TableOptions { - direction: crate::TextDirection::LeftToRight, - border: CellStroke::default(), - borders: Default::default(), - space_before: 4.0, - space_after: -4.0, - repeat_header: true, - repeat_footer: true, - alt_rows: None, - alt_cols: None, - row_strokes: None, - column_strokes: None, - } - } -} +pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions}; +pub use grid::CellRange; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -371,906 +40,6 @@ pub struct Table { pub style: String, } -/// A rectangular range of cells (inclusive). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CellRange { - pub r0: usize, - pub c0: usize, - pub r1: usize, - pub c1: usize, -} - -impl CellRange { - pub fn cell(r: usize, c: usize) -> Self { - CellRange { r0: r, c0: c, r1: r, c1: c } - } - pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self { - CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) } - } - pub fn contains(&self, r: usize, c: usize) -> bool { - r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1 - } -} - -impl Table { - /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. - pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { - let cols = cols.max(1); - let mut rows = Vec::new(); - rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); - rows.extend((0..body_rows.max(1)).map(|_| Row::default())); - rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() })); - let w = (width / cols as f64).max(3.0); - let n = rows.len() * cols; - Table { - id, - rows, - columns: vec![Column { width: w }; cols], - cells: vec![Cell::default(); n], - options: TableOptions::default(), - style: String::new(), - } - } - - /// A table from rows of cell strings (Convert Text to Table). Short rows are padded. - pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { - let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); - let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); - for (r, row) in data.iter().enumerate() { - for (c, s) in row.iter().enumerate() { - let Some(cell) = t.cell_mut(r, c) else { continue }; - cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); - cell.text.chars[0].format = fmt.clone(); - cell.text.insert(0, s); - } - } - t - } - - pub fn nrows(&self) -> usize { - self.rows.len() - } - pub fn ncols(&self) -> usize { - self.columns.len() - } - pub fn width(&self) -> f64 { - self.columns.iter().map(|c| c.width).sum() - } - fn idx(&self, r: usize, c: usize) -> Option { - (r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c) - } - pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { - self.cells.get(self.idx(r, c)?) - } - pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { - let i = self.idx(r, c)?; - self.cells.get_mut(i) - } - pub fn header_rows(&self) -> usize { - self.rows.iter().take_while(|r| r.kind == RowKind::Header).count() - } - pub fn footer_rows(&self) -> usize { - self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count() - } - pub fn body_rows(&self) -> std::ops::Range { - self.header_rows()..self.nrows() - self.footer_rows() - } - - /// For every grid position, the (row, col) of the cell that owns it. - pub fn owners(&self) -> Vec<(usize, usize)> { - let (nr, nc) = (self.nrows(), self.ncols()); - let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect(); - for r in 0..nr { - for c in 0..nc { - let cell = &self.cells[r * nc + c]; - if v[r * nc + c] != (r, c) { - continue; - } - for rr in r..(r + cell.row_span.max(1) as usize).min(nr) { - for cc in c..(c + cell.col_span.max(1) as usize).min(nc) { - v[rr * nc + cc] = (r, c); - } - } - } - } - v - } - - /// Owning cells touched by the range, once each in row-major order. Bounds are clipped - /// to the grid; resolving covered positions does not expand the selection rectangle. - pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { - let (nr, nc) = (self.nrows(), self.ncols()); - if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { - return Vec::new(); - } - let owners = self.owners(); - let mut selected = std::collections::BTreeSet::new(); - for r in range.r0..=range.r1.min(nr - 1) { - for c in range.c0..=range.c1.min(nc - 1) { - if let Some(&owner) = owners.get(r * nc + c) { - selected.insert(owner); - } - } - } - selected.into_iter().collect() - } - - /// The owning (top-left) cell address of grid position (r, c). - pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { - self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) - } - - /// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction). - pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> { - let owners = self.owners(); - let mut v = Vec::new(); - for r in 0..self.nrows() { - for c in 0..self.ncols() { - let cell = &self.cells[r * self.ncols() + c]; - if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) { - v.push((r, c, cell.row_span as usize, cell.col_span as usize)); - } - } - } - v - } - - fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) { - for c in &mut self.cells { - c.row_span = 1; - c.col_span = 1; - } - for &(r, c, rs, cs) in regions { - if rs == 0 || cs == 0 { - continue; - } - if let Some(cell) = self.cell_mut(r, c) { - cell.row_span = rs as u32; - cell.col_span = cs as u32; - } - } - } - - /// Insert `n` rows before row `at` (formatted like the neighbouring row). - pub fn insert_rows(&mut self, at: usize, n: usize) { - let at = at.min(self.nrows()); - if n == 0 { - return; - } - let nc = self.ncols(); - let src = if at < self.nrows() { at } else { at.saturating_sub(1) }; - let mut row = self.rows.get(src).cloned().unwrap_or_default(); - // A row inserted between body rows (or after the last header / before the first footer) is a body row. - if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind { - row.kind = RowKind::Body; - } - if at == self.nrows() && row.kind == RowKind::Header { - row.kind = RowKind::Body; - } - if at == 0 && row.kind == RowKind::Footer { - row.kind = RowKind::Body; - } - let template: Vec = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect(); - let regions: Vec<_> = self - .regions() - .into_iter() - .map(|(r, c, rs, cs)| { - if r >= at { - (r + n, c, rs, cs) - } else if r + rs > at { - (r, c, rs + n, cs) - } else { - (r, c, rs, cs) - } - }) - .collect(); - for k in 0..n { - self.rows.insert(at + k, row.clone()); - let i = (at + k) * nc; - for (c, cell) in template.iter().enumerate() { - self.cells.insert(i + c, cell.clone()); - } - } - self.set_regions(®ions); - } - - /// Delete rows `at..at+n` (at least one row always remains). - pub fn delete_rows(&mut self, at: usize, n: usize) { - let nr = self.nrows(); - let at = at.min(nr); - let n = n.min(nr - at).min(nr.saturating_sub(1)); - if n == 0 { - return; - } - let nc = self.ncols(); - let end = at + n; - let mut regions = Vec::new(); - for (r, c, rs, cs) in self.regions() { - let r1 = r + rs; - if r >= at && r1 <= end { - continue; // removed entirely - } - let new_rs = rs - (r1.min(end).saturating_sub(r.max(at))); - if r >= at && r < end { - // The owner row goes away: move its content to the first surviving row. - let content = self.cells[r * nc + c].clone(); - self.cells[end * nc + c] = content; - regions.push((at, c, new_rs, cs)); - } else if r < at { - regions.push((r, c, new_rs, cs)); - } else { - regions.push((r - n, c, rs, cs)); - } - } - self.rows.drain(at..end); - self.cells.drain(at * nc..end * nc); - self.set_regions(®ions); - } - - /// Insert `n` columns of `width` before column `at`. - pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) { - let at = at.min(self.ncols()); - if n == 0 { - return; - } - let nc = self.ncols(); - let src = if at < nc { at } else { at.saturating_sub(1) }; - let regions: Vec<_> = self - .regions() - .into_iter() - .map(|(r, c, rs, cs)| { - if c >= at { - (r, c + n, rs, cs) - } else if c + cs > at { - (r, c, rs, cs + n) - } else { - (r, c, rs, cs) - } - }) - .collect(); - let mut cells = Vec::with_capacity(self.nrows() * (nc + n)); - for r in 0..self.nrows() { - for c in 0..nc { - if c == at { - let t = self.cells[r * nc + src].blank_like(); - cells.extend(std::iter::repeat_n(t, n)); - } - cells.push(self.cells[r * nc + c].clone()); - } - if at == nc { - let t = self.cells[r * nc + src].blank_like(); - cells.extend(std::iter::repeat_n(t, n)); - } - } - self.cells = cells; - for k in 0..n { - self.columns.insert(at + k, Column { width }); - } - self.set_regions(®ions); - } - - /// Delete columns `at..at+n` (at least one column always remains). - pub fn delete_cols(&mut self, at: usize, n: usize) { - let nc = self.ncols(); - let at = at.min(nc); - let n = n.min(nc - at).min(nc.saturating_sub(1)); - if n == 0 { - return; - } - let end = at + n; - let mut regions = Vec::new(); - for (r, c, rs, cs) in self.regions() { - let c1 = c + cs; - if c >= at && c1 <= end { - continue; - } - let new_cs = cs - (c1.min(end).saturating_sub(c.max(at))); - if c >= at && c < end { - let content = self.cells[r * nc + c].clone(); - self.cells[r * nc + end] = content; - regions.push((r, at, rs, new_cs)); - } else if c < at { - regions.push((r, c, rs, new_cs)); - } else { - regions.push((r, c - n, rs, cs)); - } - } - let mut cells = Vec::with_capacity(self.nrows() * (nc - n)); - for r in 0..self.nrows() { - for c in 0..nc { - if c < at || c >= end { - cells.push(self.cells[r * nc + c].clone()); - } - } - } - self.cells = cells; - self.columns.drain(at..end); - self.set_regions(®ions); - } - - /// The smallest range containing `r` that doesn't cut through a merged region. - pub fn expand_range(&self, mut r: CellRange) -> CellRange { - loop { - let mut grown = r; - for (rr, cc, rs, cs) in self.regions() { - let (re, ce) = (rr + rs - 1, cc + cs - 1); - let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0; - if overlaps { - grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) }; - } - } - if grown == r { - return r; - } - r = grown; - } - } - - /// Merge a range into its top-left cell; the other cells' text is appended as paragraphs. - pub fn merge(&mut self, range: CellRange) -> Result<(), String> { - if range.r1 >= self.nrows() || range.c1 >= self.ncols() { - return Err("cell range out of bounds".into()); - } - let range = self.expand_range(range); - if range.r0 == range.r1 && range.c0 == range.c1 { - return Ok(()); - } - let owners = self.owners(); - let nc = self.ncols(); - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect(); - let mut extra: Vec = Vec::new(); - for r in range.r0..=range.r1 { - for c in range.c0..=range.c1 { - if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) { - let t = std::mem::take(&mut self.cells[r * nc + c]); - self.cells[r * nc + c] = t.blank_like(); - if !t.text.is_empty() { - extra.push(t.text); - } - } - } - } - let owner = &mut self.cells[range.r0 * nc + range.c0]; - for s in extra { - append_story(&mut owner.text, &s); - } - regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1)); - self.set_regions(®ions); - Ok(()) - } - - /// Unmerge the merged cell covering (r, c). - pub fn unmerge(&mut self, r: usize, c: usize) { - let (or, oc) = self.owner(r, c); - let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); - self.set_regions(®ions); - } - - /// Split each original owner touched by `range` once. Remember merged-cell cuts in the - /// original grid: a neighboring split may refine that grid before we reach the owner. - pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { - if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { - return Err("cell range out of bounds".into()); - } - let owners = self.owners(); - let mut selected = std::collections::BTreeSet::new(); - for r in range.r0..=range.r1 { - for c in range.c0..=range.c1 { - let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; - selected.insert(*owner); - } - } - let mut targets = Vec::new(); - for (r, c) in selected { - let cell = self.cell(r, c).ok_or("missing split cell")?; - let span = if horizontal { cell.row_span } else { cell.col_span } as usize; - let start = if horizontal { r } else { c }; - let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; - targets.push((r, c, cut)); - } - for i in 0..targets.len() { - let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; - let cell = self.cell(r, c).ok_or("missing split cell")?; - let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); - let mut blank = cell.blank_like(); - // Splitting creates an empty half, not a duplicate of graphic-cell content. - blank.graphic = None; - let before = if horizontal { self.nrows() } else { self.ncols() }; - if let Some(cut) = cut { - let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; - let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); - if horizontal { - regions.push((r, c, first, cs)); - regions.push((cut, c, rs - first, cs)); - } else { - regions.push((r, c, rs, first)); - regions.push((r, cut, rs, cs - first)); - } - self.set_regions(®ions); - } else { - // An originally single-track cell may now span two tracks inserted for its - // neighbor. split_cell divides that span instead of inserting another track. - self.split_cell(r, c, horizontal); - } - let first = self.cell(r, c).ok_or("missing split half")?; - let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; - let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; - blank.row_span = second.row_span; - blank.col_span = second.col_span; - *second = blank; - let after = if horizontal { self.nrows() } else { self.ncols() }; - if after > before { - let at = if horizontal { r + 1 } else { c + 1 }; - let added = after - before; - for (row, col, cut) in targets.iter_mut().skip(i + 1) { - let axis = if horizontal { row } else { col }; - if *axis >= at { - *axis += added; - } - if let Some(cut) = cut - && *cut >= at - { - *cut += added; - } - } - } - } - Ok(()) - } - - /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell - /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a - /// new row (column) that the cells beside it span, the row height (column width) shared. - pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) { - if r >= self.nrows() || c >= self.ncols() { - return; - } - let (or, oc) = self.owner(r, c); - let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize)); - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); - let span = if horizontal { rs } else { cs }; - if span > 1 { - let a = span.div_ceil(2); - if horizontal { - regions.push((or, oc, a, cs)); - regions.push((or + a, oc, rs - a, cs)); - } else { - regions.push((or, oc, rs, a)); - regions.push((or, oc + a, rs, cs - a)); - } - self.set_regions(®ions); - return; - } - if horizontal { - let row = self.rows[or].clone(); - self.insert_rows(or + 1, 1); - self.rows[or].height = row.height / 2.0; - // Both halves keep the split row's height mode and header/body/footer role. - self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; - // insert_rows doesn't grow regions that end at the split row; everything else on it - // spans the new row now. - let mut regions: Vec<_> = self.regions(); - let (nr, nc) = (self.nrows(), self.ncols()); - let mut covered = vec![false; nr * nc]; - for &(rr, cc, rrs, ccs) in ®ions { - for y in rr..rr + rrs { - for x in cc..cc + ccs { - covered[y * nc + x] = true; - } - } - } - for reg in regions.iter_mut() { - let (rr, cc, rrs, ccs) = *reg; - if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) { - *reg = (rr, cc, rrs + 1, ccs); - } - } - for x in 0..nc { - if (oc..oc + cs).contains(&x) || covered[or * nc + x] { - continue; - } - regions.push((or, x, 2, 1)); - } - if cs > 1 { - regions.push((or + 1, oc, 1, cs)); - } - self.set_regions(®ions); - } else { - let w = self.columns[oc].width; - self.insert_cols(oc + 1, 1, w / 2.0); - self.columns[oc].width = w / 2.0; - let mut regions: Vec<_> = self.regions(); - let (nr, nc) = (self.nrows(), self.ncols()); - let mut covered = vec![false; nr * nc]; - for &(rr, cc, rrs, ccs) in ®ions { - for y in rr..rr + rrs { - for x in cc..cc + ccs { - covered[y * nc + x] = true; - } - } - } - for reg in regions.iter_mut() { - let (rr, cc, rrs, ccs) = *reg; - if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) { - *reg = (rr, cc, rrs, ccs + 1); - } - } - for y in 0..nr { - if (or..or + rs).contains(&y) || covered[y * nc + oc] { - continue; - } - regions.push((y, oc, 1, 2)); - } - if rs > 1 { - regions.push((or, oc + 1, rs, 1)); - } - self.set_regions(®ions); - } - } - - /// Move row `from` to position `to` (indices before the move). Refused when merged cells span - /// rows (the move would cut them) or when the move would take a row across a header/footer - /// boundary (`from` and `to` must be the same kind of row). - pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { - let (nr, nc) = (self.nrows(), self.ncols()); - if from >= nr || to >= nr { - return Err("no such row".into()); - } - if self.regions().iter().any(|r| r.2 > 1) { - return Err("unmerge cells that span rows first".into()); - } - if self.rows[from].kind != self.rows[to].kind { - return Err("header and footer rows must stay first and last".into()); - } - let row = self.rows.remove(from); - self.rows.insert(to, row); - let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); - let at = to * nc; - for (k, c) in cells.into_iter().enumerate() { - self.cells.insert(at + k, c); - } - Ok(()) - } - - /// Move column `from` to position `to`. Refused when merged cells span columns. - pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> { - let (nr, nc) = (self.nrows(), self.ncols()); - if from >= nc || to >= nc { - return Err("no such column".into()); - } - if self.regions().iter().any(|r| r.3 > 1) { - return Err("unmerge cells that span columns first".into()); - } - let col = self.columns.remove(from); - self.columns.insert(to, col); - for r in 0..nr { - let c = self.cells.remove(r * nc + from); - self.cells.insert(r * nc + to, c); - } - Ok(()) - } - - /// Mark the first `header` rows as header rows and the last `footer` rows as footer rows. - pub fn set_header_footer(&mut self, header: usize, footer: usize) { - let n = self.nrows(); - let header = header.min(n.saturating_sub(1)); - let footer = footer.min(n - header - 1); - for (i, r) in self.rows.iter_mut().enumerate() { - r.kind = if i < header { - RowKind::Header - } else if i >= n - footer { - RowKind::Footer - } else { - RowKind::Body - }; - } - } - - /// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind` - /// rows. Header rows grow from the top and footer rows from the bottom, so the rows must - /// touch the table's edge or the rows already converted; back to body, they must touch the - /// body. One body row stays, and no merged cell may straddle a moved boundary. - pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> { - let (r0, r1) = (r0.min(r1), r0.max(r1)); - let n = self.nrows(); - if r1 >= n { - return Err("row out of range".into()); - } - let (h, f) = (self.header_rows(), self.footer_rows()); - let body_end = n - f; - let (nh, nf) = match kind { - RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()), - RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()), - RowKind::Header => (h.max(r1 + 1), f), - RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()), - RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()), - RowKind::Footer => (h, f.max(n - r0)), - RowKind::Body => { - if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) { - return Err("select the header or footer rows next to the body".into()); - } - (if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f }) - } - }; - if (nh, nf) == (h, f) { - return Err("the rows already are of that kind".into()); - } - if nh + nf >= n { - return Err("a table keeps at least one body row".into()); - } - let owners = self.owners(); - let nc = self.ncols(); - let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b)); - if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) { - return Err("unmerge the cells that span header, body and footer rows first".into()); - } - Ok((nh, nf)) - } - - /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). - pub fn plain_text(&self) -> String { - let mut out = String::new(); - for r in 0..self.nrows() { - if r > 0 { - out.push('\n'); - } - for c in 0..self.ncols() { - if c > 0 { - out.push('\t'); - } - if let Some(cell) = self.cell(r, c) { - out.push_str(&cell.text.text.replace('\n', " ")); - } - } - } - out - } - - pub fn check(&self) -> Result<(), String> { - if self.rows.is_empty() || self.columns.is_empty() { - return Err(format!("table {}: no rows or columns", self.id)); - } - if self.cells.len() != self.nrows() * self.ncols() { - return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols())); - } - // Header rows first, footer rows last. - let (h, f) = (self.header_rows(), self.footer_rows()); - if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) { - return Err(format!("table {}: header/footer rows out of order", self.id)); - } - self.check_spans()?; - for cell in &self.cells { - cell.text.check()?; - } - Ok(()) - } - - /// Every merged cell stays inside the grid and no two merged cells overlap. - fn check_spans(&self) -> Result<(), String> { - let mut seen = vec![false; self.cells.len()]; - for r in 0..self.nrows() { - for c in 0..self.ncols() { - let cell = &self.cells[r * self.ncols() + c]; - if cell.row_span == 0 || cell.col_span == 0 { - return Err(format!("table {}: zero span at {r},{c}", self.id)); - } - if cell.row_span == 1 && cell.col_span == 1 { - continue; - } - if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() { - return Err(format!("table {}: span out of bounds at {r},{c}", self.id)); - } - for rr in r..r + cell.row_span as usize { - for cc in c..c + cell.col_span as usize { - let i = rr * self.ncols() + cc; - if seen[i] { - return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id)); - } - seen[i] = true; - } - } - } - } - Ok(()) - } -} - -/// Append `other` to `s` as new paragraphs. -fn append_story(s: &mut Story, other: &Story) { - if s.is_empty() { - *s = Story { id: s.id, ..other.clone() }; - s.rev = s.rev.wrapping_add(1); - return; - } - let base = s.len(); - s.insert(base, "\n"); - // Copy other's runs with their formats. - for (r, f) in other.runs() { - let at = s.len(); - s.insert_with(at, &other.text[r], f.clone()); - } - let first_new = s.paras.len() - other.paras.len(); - for (k, p) in other.paras.iter().enumerate() { - if let Some(dst) = s.paras.get_mut(first_new + k) { - *dst = p.clone(); - } - } -} - -// ---------- tables in stories ---------- - -impl crate::Document { - /// The story text edits go to: a cell's story when `cell` is set, else the story itself. - pub fn text_story(&self, story: StoryId, cell: Option) -> Option<&Story> { - let st = self.story(story)?; - match cell { - Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text), - Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text), - None => Some(st), - } - } - - /// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision). - pub fn text_story_mut(&mut self, story: StoryId, cell: Option) -> Option<&mut Story> { - let st = self.story_mut(story)?; - match cell { - Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text), - Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text), - None => Some(st), - } - } -} - -impl Story { - /// Visit this story and every text inside it (table cells, footnotes), mutably. - pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) { - f(self); - for t in self.tables.values_mut() { - for c in &mut std::sync::Arc::make_mut(t).cells { - c.text.for_each_text_mut(f); - } - } - for n in &mut self.notes { - std::sync::Arc::make_mut(n).text.for_each_text_mut(f); - } - } - - /// The table whose anchor paragraph is `pi`. - pub fn para_table(&self, pi: usize) -> Option<&Arc> { - self.tables.get(&self.paras.get(pi)?.table?) - } - - /// Byte offset of a table's anchor character. - pub fn table_anchor(&self, id: u64) -> Option { - let pi = self.paras.iter().position(|p| p.table == Some(id))?; - let r = self.para_ranges().get(pi)?.clone(); - self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k) - } - - /// The table anchored at or right before byte `pos` (a caret next to the anchor). - pub fn table_at(&self, pos: usize) -> Option { - let pi = self.para_at(pos); - self.paras.get(pi)?.table - } - - /// Mutable table (copy-on-write); bumps the story revision. - pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> { - let t = self.tables.get_mut(&id)?; - self.rev = self.rev.wrapping_add(1); - Some(Arc::make_mut(t)) - } - - /// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset. - pub fn insert_table(&mut self, pos: usize, table: Table) -> usize { - let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len())); - let pi = self.para_at(pos); - let pstart = self.para_ranges()[pi].start; - let mut s = String::new(); - if pos > pstart { - s.push('\n'); - } - let anchor = pos + s.len(); - s.push(TABLE_ANCHOR); - if !self.text[pos..].starts_with('\n') { - s.push('\n'); - } - let id = table.id; - self.tables.insert(id, Arc::new(table)); - // Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it. - self.insert_raw(pos, &s); - let api = self.para_at(anchor); - self.paras[api].table = Some(id); - self.fix_tables(); - self.rev = self.rev.wrapping_add(1); - anchor - } - - /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it - /// still contains an anchor character, every table is referenced once, and tables nobody - /// references are dropped. An anchor that shares a table's paragraph with another anchor gets - /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. - pub(crate) fn fix_tables(&mut self) { - self.fix_tables_with(&[]); - } - - /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are - /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph - /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other - /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before - /// the fix-up, where it inserted paragraph returns. - pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { - if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { - return Vec::new(); - } - let mut owner = std::collections::BTreeMap::new(); - let mut used = std::collections::HashSet::new(); - for &(at, id) in claims { - if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) - && self.tables.contains_key(&id) - && !owner.contains_key(&at) - && used.insert(id) - { - owner.insert(at, id); - } - } - let ranges = self.para_ranges(); - for (p, r) in self.paras.iter().zip(&ranges) { - let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; - let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); - if let Some(at) = free { - used.insert(id); - owner.insert(at, id); - } - } - // A paragraph holding a table's anchor and other anchors is split before each later anchor. - let mut splits = Vec::new(); - for r in &ranges { - let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); - if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { - splits.extend(anchors.into_iter().skip(1)); - } - } - for &at in splits.iter().rev() { - self.insert_raw(at, "\n"); - } - // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. - let owner: std::collections::BTreeMap = - owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); - let ranges = self.para_ranges(); - for (p, r) in self.paras.iter_mut().zip(ranges) { - p.table = owner.range(r).next().map(|(_, &id)| id); - } - self.tables.retain(|id, _| used.contains(id)); - splits - } - - /// Check table invariants (part of [`Story::check`]). - pub(crate) fn check_tables(&self) -> Result<(), String> { - let ranges = self.para_ranges(); - let mut used = std::collections::HashSet::new(); - for (pi, p) in self.paras.iter().enumerate() { - let Some(id) = p.table else { continue }; - let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?; - if t.id != id { - return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id)); - } - if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 { - return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0)); - } - if !used.insert(id) { - return Err(format!("story {}: table {id} anchored twice", self.id.0)); - } - t.check()?; - } - if used.len() != self.tables.len() { - return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len())); - } - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1510,115 +279,3 @@ mod tests { assert_eq!(back.blank_like().border_overrides, explicit.border_overrides); } } - -#[cfg(test)] -mod split_tests { - use super::*; - - #[test] - fn split_single_and_merged_cells() { - let mut t = Table::new(1, 2, 2, 0, 0, 100.0); - t.split_cell(0, 0, true); - assert_eq!((t.nrows(), t.ncols()), (3, 2)); - // The cell beside the split one spans both new rows. - assert_eq!(t.cell(0, 1).unwrap().row_span, 2); - assert_eq!(t.owner(1, 1), (0, 1)); - assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell"); - t.split_cell(2, 1, false); - assert_eq!(t.ncols(), 3); - assert_eq!(t.cell(0, 0).unwrap().col_span, 1); - assert_eq!(t.owner(2, 2), (2, 2)); - assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens"); - // A merged cell splits its own span instead of adding rows. - let mut m = Table::new(1, 4, 1, 0, 0, 100.0); - m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap(); - m.split_cell(0, 0, true); - assert_eq!(m.nrows(), 4); - assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); - } - - #[test] - fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { - for horizontal in [true, false] { - let mut t = Table::new(1, 4, 4, 0, 0, 280.0); - for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { - t.rows[i].mode = RowHeightMode::Exactly; - t.rows[i].height = if horizontal { size } else { 70.0 }; - t.columns[i].width = if horizontal { 70.0 } else { size }; - } - for r in 0..4 { - for c in 0..4 { - let cell = t.cell_mut(r, c).unwrap(); - cell.text.insert(0, &format!("{r},{c}")); - cell.style = format!("style-{r}-{c}"); - cell.insets = [r as f64 + c as f64 + 1.0; 4]; - } - } - let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { - (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) - } else { - (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) - }; - t.merge(merged).unwrap(); - let original = t.clone(); - t.split_cells(selected, horizontal).unwrap(); - t.check().unwrap(); - assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); - let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; - assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); - let first = t.cell(owner.0, owner.1).unwrap(); - let second = t.cell(lower.0, lower.1).unwrap(); - // The original three-track merge divides after its second original track, - // regardless of which of those tracks a neighboring selected cell subdivides. - assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); - assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); - assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); - assert!(second.text.is_empty()); - assert_eq!(second.style, first.style); - assert_eq!(second.insets, first.insets); - assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); - let owners = t.owners(); - assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); - let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); - let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); - before_text.sort(); - after_text.sort(); - assert_eq!(after_text, before_text, "no original text is copied or lost"); - } - } - - #[test] - fn split_range_resolves_covered_positions_without_expanding_selection() { - for horizontal in [true, false] { - let mut t = Table::new(1, 3, 3, 0, 0, 180.0); - t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); - let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; - t.split_cells(selected, horizontal).unwrap(); - t.check().unwrap(); - let owners = t.owners(); - assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); - assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); - assert_eq!(t.owner(0, 0), (0, 0)); - assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); - } - } - - #[test] - fn split_header_row_keeps_its_height_mode_and_role() { - let mut t = Table::new(1, 1, 2, 1, 1, 180.0); - t.rows[0].height = 60.0; - t.rows[0].mode = RowHeightMode::Exactly; - let body = t.rows[1].clone(); - let footer = t.rows[2].clone(); - t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); - t.check().unwrap(); - assert_eq!(t.header_rows(), 2); - for row in &t.rows[..2] { - assert_eq!(row.height, 30.0); - assert_eq!(row.mode, RowHeightMode::Exactly); - assert_eq!(row.kind, RowKind::Header); - } - assert_eq!(t.rows[2], body); - assert_eq!(t.rows[3], footer); - } -} diff --git a/crates/doc/src/table/cell.rs b/crates/doc/src/table/cell.rs new file mode 100644 index 0000000000..100a3474f4 --- /dev/null +++ b/crates/doc/src/table/cell.rs @@ -0,0 +1,348 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RowHeightMode { + /// Grows with the content (the height is a minimum). + #[default] + AtLeast, + Exactly, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RowKind { + Header, + #[default] + Body, + Footer, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Row { + pub height: f64, + pub mode: RowHeightMode, + pub kind: RowKind, +} + +impl Default for Row { + fn default() -> Self { + Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Column { + pub width: f64, +} + +/// One cell edge or the table border. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct CellStroke { + pub weight: f64, + pub color: String, + pub tint: f32, + pub kind: StrokeType, + /// Painted in the gaps of dashed, dotted and striped types. + #[serde(skip_serializing_if = "is_no_swatch")] + pub gap_color: String, + #[serde(skip_serializing_if = "is_full")] + pub gap_tint: f32, +} + +fn is_no_swatch(s: &str) -> bool { + s == designcraft_color::swatch::NONE +} + +fn is_full(t: &f32) -> bool { + *t == 1.0 +} + +impl Default for CellStroke { + fn default() -> Self { + CellStroke { + weight: 1.0, + color: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + kind: StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + } + } +} + +impl CellStroke { + pub fn none() -> Self { + CellStroke { weight: 0.0, ..Default::default() } + } + pub fn is_visible(&self) -> bool { + self.weight > 0.0 && self.color != designcraft_color::swatch::NONE + } +} + +/// Table-level alternating row/column strokes. Retained independently of cell formatting +/// so an explicit cell style or local edge continues to take precedence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltStrokes { + pub first: u32, + pub next: u32, + pub first_stroke: CellStroke, + pub next_stroke: CellStroke, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltStrokes { + fn default() -> Self { + Self { + first: 0, + next: 0, + first_stroke: CellStroke::default(), + next_stroke: CellStroke { weight: 0.25, ..Default::default() }, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltStrokes { + /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal + /// patterns and skip settings are preserved for round trips until their boundary + /// phase is supported; they must not be rendered with a guessed phase. + pub fn uniform_stroke(&self) -> Option<&CellStroke> { + if self.skip_first > 0 || self.skip_last > 0 { + return None; + } + match (self.first > 0, self.next > 0) { + (true, false) => Some(&self.first_stroke), + (false, true) => Some(&self.next_stroke), + (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { + Some(&self.first_stroke) + } + _ => None, + } + } +} + +fn no_flags(flags: &[bool; 4]) -> bool { + !flags.iter().any(|v| *v) +} + +fn no_priorities(priorities: &[i32; 4]) -> bool { + priorities.iter().all(|v| *v == 0) +} + +fn one() -> u32 { + 1 +} + +fn full() -> f32 { + 1.0 +} + +fn no_fill() -> String { + designcraft_color::swatch::NONE.into() +} + +fn is_ascent(f: &FirstBaseline) -> bool { + *f == FirstBaseline::Ascent +} + +fn is_zero_min(v: &f64) -> bool { + *v == 0.0 +} + +fn default_insets() -> [f64; 4] { + [4.0; 4] +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cell { + #[serde(default = "one")] + pub row_span: u32, + #[serde(default = "one")] + pub col_span: u32, + /// Cell content (a frameless story). + pub text: Story, + #[serde(default = "no_fill")] + pub fill: String, + #[serde(default = "full")] + pub fill_tint: f32, + /// Top, left, bottom, right. + #[serde(default = "default_insets")] + pub insets: [f64; 4], + #[serde(default)] + pub vj: VerticalJustification, + /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the + /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). + #[serde(default, skip_serializing_if = "is_ascent")] + pub first_baseline: FirstBaseline, + #[serde(default, skip_serializing_if = "is_zero_min")] + pub first_baseline_min: f64, + /// Text rotation in degrees (only 0 is composed for now). + #[serde(default)] + pub rotation: f64, + /// Edge strokes: top, left, bottom, right. + #[serde(default)] + pub strokes: [CellStroke; 4], + /// Explicit cell-edge formatting takes precedence over the table border, in the same + /// top, left, bottom, right order as `strokes`. Older documents keep their table border. + #[serde(default)] + pub border_overrides: [bool; 4], + /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. + #[serde(default, skip_serializing_if = "no_flags")] + pub stroke_defined: [bool; 4], + /// IDML edge-conflict priorities, in top, left, bottom, right order. + #[serde(default, skip_serializing_if = "no_priorities")] + pub stroke_priorities: [i32; 4], + /// Applied cell style ("" = [None]). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style: String, + /// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's + /// content box, origin at the box's top left). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graphic: Option, +} + +impl Default for Cell { + fn default() -> Self { + Cell { + row_span: 1, + col_span: 1, + text: Story::new(StoryId(0)), + fill: no_fill(), + fill_tint: 1.0, + insets: default_insets(), + vj: VerticalJustification::Top, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + rotation: 0.0, + strokes: Default::default(), + border_overrides: [false; 4], + stroke_priorities: [0; 4], + stroke_defined: [false; 4], + style: String::new(), + graphic: None, + } + } +} + +impl Cell { + pub fn with_text(text: &str, para: ParaFormat) -> Cell { + Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() } + } + /// A cell with this cell's formatting and no text (new rows / columns, unmerged cells). + pub fn blank_like(&self) -> Cell { + let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() }; + let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default(); + let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default(); + c.text = Story::new(StoryId(0)); + c.text.paras[0] = para; + c.text.chars[0].format = fmt; + c + } + pub fn has_fill(&self) -> bool { + self.fill != designcraft_color::swatch::NONE + } +} + +/// Table → Table Options → Fills (alternating pattern). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltFills { + pub first: u32, + pub first_color: String, + pub first_tint: f32, + pub next: u32, + pub next_color: String, + pub next_tint: f32, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltFills { + fn default() -> Self { + AltFills { + first: 1, + first_color: designcraft_color::swatch::BLACK.into(), + first_tint: 0.2, + next: 1, + next_color: designcraft_color::swatch::NONE.into(), + next_tint: 1.0, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltFills { + /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. + pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { + let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); + if i < sf || i >= n.saturating_sub(sl) { + return None; + } + let period = (u64::from(self.first) + u64::from(self.next)).max(1); + let k = (i - sf) as u64 % period; + let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; + (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TableOptions { + /// Logical column zero is drawn at the right edge for RTL tables. + pub direction: crate::TextDirection, + /// Outer border (overrides the outer cell edges). + pub border: CellStroke, + /// Per-side borders; absent sides use the legacy uniform `border`. + #[serde(skip_serializing_if = "no_borders")] + pub borders: [Option; 4], + pub space_before: f64, + pub space_after: f64, + /// Header rows repeat at the top of every text column / frame the table continues into. + pub repeat_header: bool, + pub repeat_footer: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_cols: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub row_strokes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column_strokes: Option, +} + +fn no_borders(borders: &[Option; 4]) -> bool { + borders.iter().all(Option::is_none) +} + +impl TableOptions { + pub fn border_for(&self, side: usize) -> &CellStroke { + self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) + } +} + +impl Default for TableOptions { + fn default() -> Self { + TableOptions { + direction: crate::TextDirection::LeftToRight, + border: CellStroke::default(), + borders: Default::default(), + space_before: 4.0, + space_after: -4.0, + repeat_header: true, + repeat_footer: true, + alt_rows: None, + alt_cols: None, + row_strokes: None, + column_strokes: None, + } + } +} diff --git a/crates/doc/src/table/edit.rs b/crates/doc/src/table/edit.rs new file mode 100644 index 0000000000..589c9666e6 --- /dev/null +++ b/crates/doc/src/table/edit.rs @@ -0,0 +1,260 @@ +use super::cell::{Cell, Column, RowKind}; +use super::*; + +impl Table { + /// Insert `n` rows before row `at` (formatted like the neighbouring row). + pub fn insert_rows(&mut self, at: usize, n: usize) { + let at = at.min(self.nrows()); + if n == 0 { + return; + } + let nc = self.ncols(); + let src = if at < self.nrows() { at } else { at.saturating_sub(1) }; + let mut row = self.rows.get(src).cloned().unwrap_or_default(); + // A row inserted between body rows (or after the last header / before the first footer) is a body row. + if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind { + row.kind = RowKind::Body; + } + if at == self.nrows() && row.kind == RowKind::Header { + row.kind = RowKind::Body; + } + if at == 0 && row.kind == RowKind::Footer { + row.kind = RowKind::Body; + } + let template: Vec = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect(); + let regions: Vec<_> = self + .regions() + .into_iter() + .map(|(r, c, rs, cs)| { + if r >= at { + (r + n, c, rs, cs) + } else if r + rs > at { + (r, c, rs + n, cs) + } else { + (r, c, rs, cs) + } + }) + .collect(); + for k in 0..n { + self.rows.insert(at + k, row.clone()); + let i = (at + k) * nc; + for (c, cell) in template.iter().enumerate() { + self.cells.insert(i + c, cell.clone()); + } + } + self.set_regions(®ions); + } + + /// Delete rows `at..at+n` (at least one row always remains). + pub fn delete_rows(&mut self, at: usize, n: usize) { + let nr = self.nrows(); + let at = at.min(nr); + let n = n.min(nr - at).min(nr.saturating_sub(1)); + if n == 0 { + return; + } + let nc = self.ncols(); + let end = at + n; + let mut regions = Vec::new(); + for (r, c, rs, cs) in self.regions() { + let r1 = r + rs; + if r >= at && r1 <= end { + continue; // removed entirely + } + let new_rs = rs - (r1.min(end).saturating_sub(r.max(at))); + if r >= at && r < end { + // The owner row goes away: move its content to the first surviving row. + let content = self.cells[r * nc + c].clone(); + self.cells[end * nc + c] = content; + regions.push((at, c, new_rs, cs)); + } else if r < at { + regions.push((r, c, new_rs, cs)); + } else { + regions.push((r - n, c, rs, cs)); + } + } + self.rows.drain(at..end); + self.cells.drain(at * nc..end * nc); + self.set_regions(®ions); + } + + /// Insert `n` columns of `width` before column `at`. + pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) { + let at = at.min(self.ncols()); + if n == 0 { + return; + } + let nc = self.ncols(); + let src = if at < nc { at } else { at.saturating_sub(1) }; + let regions: Vec<_> = self + .regions() + .into_iter() + .map(|(r, c, rs, cs)| { + if c >= at { + (r, c + n, rs, cs) + } else if c + cs > at { + (r, c, rs, cs + n) + } else { + (r, c, rs, cs) + } + }) + .collect(); + let mut cells = Vec::with_capacity(self.nrows() * (nc + n)); + for r in 0..self.nrows() { + for c in 0..nc { + if c == at { + let t = self.cells[r * nc + src].blank_like(); + cells.extend(std::iter::repeat_n(t, n)); + } + cells.push(self.cells[r * nc + c].clone()); + } + if at == nc { + let t = self.cells[r * nc + src].blank_like(); + cells.extend(std::iter::repeat_n(t, n)); + } + } + self.cells = cells; + for k in 0..n { + self.columns.insert(at + k, Column { width }); + } + self.set_regions(®ions); + } + + /// Delete columns `at..at+n` (at least one column always remains). + pub fn delete_cols(&mut self, at: usize, n: usize) { + let nc = self.ncols(); + let at = at.min(nc); + let n = n.min(nc - at).min(nc.saturating_sub(1)); + if n == 0 { + return; + } + let end = at + n; + let mut regions = Vec::new(); + for (r, c, rs, cs) in self.regions() { + let c1 = c + cs; + if c >= at && c1 <= end { + continue; + } + let new_cs = cs - (c1.min(end).saturating_sub(c.max(at))); + if c >= at && c < end { + let content = self.cells[r * nc + c].clone(); + self.cells[r * nc + end] = content; + regions.push((r, at, rs, new_cs)); + } else if c < at { + regions.push((r, c, rs, new_cs)); + } else { + regions.push((r, c - n, rs, cs)); + } + } + let mut cells = Vec::with_capacity(self.nrows() * (nc - n)); + for r in 0..self.nrows() { + for c in 0..nc { + if c < at || c >= end { + cells.push(self.cells[r * nc + c].clone()); + } + } + } + self.cells = cells; + self.columns.drain(at..end); + self.set_regions(®ions); + } + + /// Move row `from` to position `to` (indices before the move). Refused when merged cells span + /// rows (the move would cut them) or when the move would take a row across a header/footer + /// boundary (`from` and `to` must be the same kind of row). + pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { + let (nr, nc) = (self.nrows(), self.ncols()); + if from >= nr || to >= nr { + return Err("no such row".into()); + } + if self.regions().iter().any(|r| r.2 > 1) { + return Err("unmerge cells that span rows first".into()); + } + if self.rows[from].kind != self.rows[to].kind { + return Err("header and footer rows must stay first and last".into()); + } + let row = self.rows.remove(from); + self.rows.insert(to, row); + let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); + let at = to * nc; + for (k, c) in cells.into_iter().enumerate() { + self.cells.insert(at + k, c); + } + Ok(()) + } + + /// Move column `from` to position `to`. Refused when merged cells span columns. + pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> { + let (nr, nc) = (self.nrows(), self.ncols()); + if from >= nc || to >= nc { + return Err("no such column".into()); + } + if self.regions().iter().any(|r| r.3 > 1) { + return Err("unmerge cells that span columns first".into()); + } + let col = self.columns.remove(from); + self.columns.insert(to, col); + for r in 0..nr { + let c = self.cells.remove(r * nc + from); + self.cells.insert(r * nc + to, c); + } + Ok(()) + } + + /// Mark the first `header` rows as header rows and the last `footer` rows as footer rows. + pub fn set_header_footer(&mut self, header: usize, footer: usize) { + let n = self.nrows(); + let header = header.min(n.saturating_sub(1)); + let footer = footer.min(n - header - 1); + for (i, r) in self.rows.iter_mut().enumerate() { + r.kind = if i < header { + RowKind::Header + } else if i >= n - footer { + RowKind::Footer + } else { + RowKind::Body + }; + } + } + + /// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind` + /// rows. Header rows grow from the top and footer rows from the bottom, so the rows must + /// touch the table's edge or the rows already converted; back to body, they must touch the + /// body. One body row stays, and no merged cell may straddle a moved boundary. + pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> { + let (r0, r1) = (r0.min(r1), r0.max(r1)); + let n = self.nrows(); + if r1 >= n { + return Err("row out of range".into()); + } + let (h, f) = (self.header_rows(), self.footer_rows()); + let body_end = n - f; + let (nh, nf) = match kind { + RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()), + RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()), + RowKind::Header => (h.max(r1 + 1), f), + RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()), + RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()), + RowKind::Footer => (h, f.max(n - r0)), + RowKind::Body => { + if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) { + return Err("select the header or footer rows next to the body".into()); + } + (if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f }) + } + }; + if (nh, nf) == (h, f) { + return Err("the rows already are of that kind".into()); + } + if nh + nf >= n { + return Err("a table keeps at least one body row".into()); + } + let owners = self.owners(); + let nc = self.ncols(); + let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b)); + if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) { + return Err("unmerge the cells that span header, body and footer rows first".into()); + } + Ok((nh, nf)) + } +} diff --git a/crates/doc/src/table/grid.rs b/crates/doc/src/table/grid.rs new file mode 100644 index 0000000000..48647cffc5 --- /dev/null +++ b/crates/doc/src/table/grid.rs @@ -0,0 +1,239 @@ +use super::cell::{Cell, Column, Row, RowKind, TableOptions}; +use super::*; + +/// A rectangular range of cells (inclusive). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CellRange { + pub r0: usize, + pub c0: usize, + pub r1: usize, + pub c1: usize, +} + +impl CellRange { + pub fn cell(r: usize, c: usize) -> Self { + CellRange { r0: r, c0: c, r1: r, c1: c } + } + pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self { + CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) } + } + pub fn contains(&self, r: usize, c: usize) -> bool { + r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1 + } +} + +impl Table { + /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. + pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { + let cols = cols.max(1); + let mut rows = Vec::new(); + rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); + rows.extend((0..body_rows.max(1)).map(|_| Row::default())); + rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() })); + let w = (width / cols as f64).max(3.0); + let n = rows.len() * cols; + Table { + id, + rows, + columns: vec![Column { width: w }; cols], + cells: vec![Cell::default(); n], + options: TableOptions::default(), + style: String::new(), + } + } + + /// A table from rows of cell strings (Convert Text to Table). Short rows are padded. + pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { + let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); + let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); + for (r, row) in data.iter().enumerate() { + for (c, s) in row.iter().enumerate() { + let Some(cell) = t.cell_mut(r, c) else { continue }; + cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); + cell.text.chars[0].format = fmt.clone(); + cell.text.insert(0, s); + } + } + t + } + + pub fn nrows(&self) -> usize { + self.rows.len() + } + + pub fn ncols(&self) -> usize { + self.columns.len() + } + + pub fn width(&self) -> f64 { + self.columns.iter().map(|c| c.width).sum() + } + + fn idx(&self, r: usize, c: usize) -> Option { + (r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c) + } + + pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { + self.cells.get(self.idx(r, c)?) + } + + pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { + let i = self.idx(r, c)?; + self.cells.get_mut(i) + } + + pub fn header_rows(&self) -> usize { + self.rows.iter().take_while(|r| r.kind == RowKind::Header).count() + } + + pub fn footer_rows(&self) -> usize { + self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count() + } + + pub fn body_rows(&self) -> std::ops::Range { + self.header_rows()..self.nrows() - self.footer_rows() + } + + /// For every grid position, the (row, col) of the cell that owns it. + pub fn owners(&self) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect(); + for r in 0..nr { + for c in 0..nc { + let cell = &self.cells[r * nc + c]; + if v[r * nc + c] != (r, c) { + continue; + } + for rr in r..(r + cell.row_span.max(1) as usize).min(nr) { + for cc in c..(c + cell.col_span.max(1) as usize).min(nc) { + v[rr * nc + cc] = (r, c); + } + } + } + } + v + } + + /// Owning cells touched by the range, once each in row-major order. Bounds are clipped + /// to the grid; resolving covered positions does not expand the selection rectangle. + pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { + return Vec::new(); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1.min(nr - 1) { + for c in range.c0..=range.c1.min(nc - 1) { + if let Some(&owner) = owners.get(r * nc + c) { + selected.insert(owner); + } + } + } + selected.into_iter().collect() + } + + /// The owning (top-left) cell address of grid position (r, c). + pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { + self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) + } + + /// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction). + pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> { + let owners = self.owners(); + let mut v = Vec::new(); + for r in 0..self.nrows() { + for c in 0..self.ncols() { + let cell = &self.cells[r * self.ncols() + c]; + if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) { + v.push((r, c, cell.row_span as usize, cell.col_span as usize)); + } + } + } + v + } + + pub(super) fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) { + for c in &mut self.cells { + c.row_span = 1; + c.col_span = 1; + } + for &(r, c, rs, cs) in regions { + if rs == 0 || cs == 0 { + continue; + } + if let Some(cell) = self.cell_mut(r, c) { + cell.row_span = rs as u32; + cell.col_span = cs as u32; + } + } + } + + /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). + pub fn plain_text(&self) -> String { + let mut out = String::new(); + for r in 0..self.nrows() { + if r > 0 { + out.push('\n'); + } + for c in 0..self.ncols() { + if c > 0 { + out.push('\t'); + } + if let Some(cell) = self.cell(r, c) { + out.push_str(&cell.text.text.replace('\n', " ")); + } + } + } + out + } + + pub fn check(&self) -> Result<(), String> { + if self.rows.is_empty() || self.columns.is_empty() { + return Err(format!("table {}: no rows or columns", self.id)); + } + if self.cells.len() != self.nrows() * self.ncols() { + return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols())); + } + // Header rows first, footer rows last. + let (h, f) = (self.header_rows(), self.footer_rows()); + if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) { + return Err(format!("table {}: header/footer rows out of order", self.id)); + } + self.check_spans()?; + for cell in &self.cells { + cell.text.check()?; + } + Ok(()) + } + + /// Every merged cell stays inside the grid and no two merged cells overlap. + fn check_spans(&self) -> Result<(), String> { + let mut seen = vec![false; self.cells.len()]; + for r in 0..self.nrows() { + for c in 0..self.ncols() { + let cell = &self.cells[r * self.ncols() + c]; + if cell.row_span == 0 || cell.col_span == 0 { + return Err(format!("table {}: zero span at {r},{c}", self.id)); + } + if cell.row_span == 1 && cell.col_span == 1 { + continue; + } + if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() { + return Err(format!("table {}: span out of bounds at {r},{c}", self.id)); + } + for rr in r..r + cell.row_span as usize { + for cc in c..c + cell.col_span as usize { + let i = rr * self.ncols() + cc; + if seen[i] { + return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id)); + } + seen[i] = true; + } + } + } + } + Ok(()) + } +} diff --git a/crates/doc/src/table/merge.rs b/crates/doc/src/table/merge.rs new file mode 100644 index 0000000000..f6eaae3da1 --- /dev/null +++ b/crates/doc/src/table/merge.rs @@ -0,0 +1,341 @@ +use super::cell::Row; +#[cfg(test)] +use super::cell::{RowHeightMode, RowKind}; +use super::grid::CellRange; +use super::story::append_story; +use super::*; + +impl Table { + /// The smallest range containing `r` that doesn't cut through a merged region. + pub fn expand_range(&self, mut r: CellRange) -> CellRange { + loop { + let mut grown = r; + for (rr, cc, rs, cs) in self.regions() { + let (re, ce) = (rr + rs - 1, cc + cs - 1); + let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0; + if overlaps { + grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) }; + } + } + if grown == r { + return r; + } + r = grown; + } + } + + /// Merge a range into its top-left cell; the other cells' text is appended as paragraphs. + pub fn merge(&mut self, range: CellRange) -> Result<(), String> { + if range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let range = self.expand_range(range); + if range.r0 == range.r1 && range.c0 == range.c1 { + return Ok(()); + } + let owners = self.owners(); + let nc = self.ncols(); + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect(); + let mut extra: Vec = Vec::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) { + let t = std::mem::take(&mut self.cells[r * nc + c]); + self.cells[r * nc + c] = t.blank_like(); + if !t.text.is_empty() { + extra.push(t.text); + } + } + } + } + let owner = &mut self.cells[range.r0 * nc + range.c0]; + for s in extra { + append_story(&mut owner.text, &s); + } + regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1)); + self.set_regions(®ions); + Ok(()) + } + + /// Unmerge the merged cell covering (r, c). + pub fn unmerge(&mut self, r: usize, c: usize) { + let (or, oc) = self.owner(r, c); + let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); + self.set_regions(®ions); + } + + /// Split each original owner touched by `range` once. Remember merged-cell cuts in the + /// original grid: a neighboring split may refine that grid before we reach the owner. + pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { + if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; + selected.insert(*owner); + } + } + let mut targets = Vec::new(); + for (r, c) in selected { + let cell = self.cell(r, c).ok_or("missing split cell")?; + let span = if horizontal { cell.row_span } else { cell.col_span } as usize; + let start = if horizontal { r } else { c }; + let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; + targets.push((r, c, cut)); + } + for i in 0..targets.len() { + let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; + let cell = self.cell(r, c).ok_or("missing split cell")?; + let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); + let mut blank = cell.blank_like(); + // Splitting creates an empty half, not a duplicate of graphic-cell content. + blank.graphic = None; + let before = if horizontal { self.nrows() } else { self.ncols() }; + if let Some(cut) = cut { + let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; + let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); + if horizontal { + regions.push((r, c, first, cs)); + regions.push((cut, c, rs - first, cs)); + } else { + regions.push((r, c, rs, first)); + regions.push((r, cut, rs, cs - first)); + } + self.set_regions(®ions); + } else { + // An originally single-track cell may now span two tracks inserted for its + // neighbor. split_cell divides that span instead of inserting another track. + self.split_cell(r, c, horizontal); + } + let first = self.cell(r, c).ok_or("missing split half")?; + let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; + let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; + blank.row_span = second.row_span; + blank.col_span = second.col_span; + *second = blank; + let after = if horizontal { self.nrows() } else { self.ncols() }; + if after > before { + let at = if horizontal { r + 1 } else { c + 1 }; + let added = after - before; + for (row, col, cut) in targets.iter_mut().skip(i + 1) { + let axis = if horizontal { row } else { col }; + if *axis >= at { + *axis += added; + } + if let Some(cut) = cut + && *cut >= at + { + *cut += added; + } + } + } + } + Ok(()) + } + + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell + /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a + /// new row (column) that the cells beside it span, the row height (column width) shared. + pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) { + if r >= self.nrows() || c >= self.ncols() { + return; + } + let (or, oc) = self.owner(r, c); + let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize)); + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); + let span = if horizontal { rs } else { cs }; + if span > 1 { + let a = span.div_ceil(2); + if horizontal { + regions.push((or, oc, a, cs)); + regions.push((or + a, oc, rs - a, cs)); + } else { + regions.push((or, oc, rs, a)); + regions.push((or, oc + a, rs, cs - a)); + } + self.set_regions(®ions); + return; + } + if horizontal { + let row = self.rows[or].clone(); + self.insert_rows(or + 1, 1); + self.rows[or].height = row.height / 2.0; + // Both halves keep the split row's height mode and header/body/footer role. + self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; + // insert_rows doesn't grow regions that end at the split row; everything else on it + // spans the new row now. + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) { + *reg = (rr, cc, rrs + 1, ccs); + } + } + for x in 0..nc { + if (oc..oc + cs).contains(&x) || covered[or * nc + x] { + continue; + } + regions.push((or, x, 2, 1)); + } + if cs > 1 { + regions.push((or + 1, oc, 1, cs)); + } + self.set_regions(®ions); + } else { + let w = self.columns[oc].width; + self.insert_cols(oc + 1, 1, w / 2.0); + self.columns[oc].width = w / 2.0; + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) { + *reg = (rr, cc, rrs, ccs + 1); + } + } + for y in 0..nr { + if (or..or + rs).contains(&y) || covered[y * nc + oc] { + continue; + } + regions.push((y, oc, 1, 2)); + } + if rs > 1 { + regions.push((or, oc + 1, rs, 1)); + } + self.set_regions(®ions); + } + } +} + +#[cfg(test)] +mod split_tests { + use super::*; + + #[test] + fn split_single_and_merged_cells() { + let mut t = Table::new(1, 2, 2, 0, 0, 100.0); + t.split_cell(0, 0, true); + assert_eq!((t.nrows(), t.ncols()), (3, 2)); + // The cell beside the split one spans both new rows. + assert_eq!(t.cell(0, 1).unwrap().row_span, 2); + assert_eq!(t.owner(1, 1), (0, 1)); + assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell"); + t.split_cell(2, 1, false); + assert_eq!(t.ncols(), 3); + assert_eq!(t.cell(0, 0).unwrap().col_span, 1); + assert_eq!(t.owner(2, 2), (2, 2)); + assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens"); + // A merged cell splits its own span instead of adding rows. + let mut m = Table::new(1, 4, 1, 0, 0, 100.0); + m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap(); + m.split_cell(0, 0, true); + assert_eq!(m.nrows(), 4); + assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); + } + + #[test] + fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { + for horizontal in [true, false] { + let mut t = Table::new(1, 4, 4, 0, 0, 280.0); + for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { + t.rows[i].mode = RowHeightMode::Exactly; + t.rows[i].height = if horizontal { size } else { 70.0 }; + t.columns[i].width = if horizontal { 70.0 } else { size }; + } + for r in 0..4 { + for c in 0..4 { + let cell = t.cell_mut(r, c).unwrap(); + cell.text.insert(0, &format!("{r},{c}")); + cell.style = format!("style-{r}-{c}"); + cell.insets = [r as f64 + c as f64 + 1.0; 4]; + } + } + let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { + (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) + } else { + (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) + }; + t.merge(merged).unwrap(); + let original = t.clone(); + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); + let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; + assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); + let first = t.cell(owner.0, owner.1).unwrap(); + let second = t.cell(lower.0, lower.1).unwrap(); + // The original three-track merge divides after its second original track, + // regardless of which of those tracks a neighboring selected cell subdivides. + assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); + assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); + assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); + assert!(second.text.is_empty()); + assert_eq!(second.style, first.style); + assert_eq!(second.insets, first.insets); + assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); + let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + before_text.sort(); + after_text.sort(); + assert_eq!(after_text, before_text, "no original text is copied or lost"); + } + } + + #[test] + fn split_range_resolves_covered_positions_without_expanding_selection() { + for horizontal in [true, false] { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); + assert_eq!(t.owner(0, 0), (0, 0)); + assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); + } + } + + #[test] + fn split_header_row_keeps_its_height_mode_and_role() { + let mut t = Table::new(1, 1, 2, 1, 1, 180.0); + t.rows[0].height = 60.0; + t.rows[0].mode = RowHeightMode::Exactly; + let body = t.rows[1].clone(); + let footer = t.rows[2].clone(); + t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); + t.check().unwrap(); + assert_eq!(t.header_rows(), 2); + for row in &t.rows[..2] { + assert_eq!(row.height, 30.0); + assert_eq!(row.mode, RowHeightMode::Exactly); + assert_eq!(row.kind, RowKind::Header); + } + assert_eq!(t.rows[2], body); + assert_eq!(t.rows[3], footer); + } +} diff --git a/crates/doc/src/table/story.rs b/crates/doc/src/table/story.rs new file mode 100644 index 0000000000..67573d645d --- /dev/null +++ b/crates/doc/src/table/story.rs @@ -0,0 +1,195 @@ +use super::*; + +/// Append `other` to `s` as new paragraphs. +pub(super) fn append_story(s: &mut Story, other: &Story) { + if s.is_empty() { + *s = Story { id: s.id, ..other.clone() }; + s.rev = s.rev.wrapping_add(1); + return; + } + let base = s.len(); + s.insert(base, "\n"); + // Copy other's runs with their formats. + for (r, f) in other.runs() { + let at = s.len(); + s.insert_with(at, &other.text[r], f.clone()); + } + let first_new = s.paras.len() - other.paras.len(); + for (k, p) in other.paras.iter().enumerate() { + if let Some(dst) = s.paras.get_mut(first_new + k) { + *dst = p.clone(); + } + } +} + +// ---------- tables in stories ---------- + +impl crate::Document { + /// The story text edits go to: a cell's story when `cell` is set, else the story itself. + pub fn text_story(&self, story: StoryId, cell: Option) -> Option<&Story> { + let st = self.story(story)?; + match cell { + Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text), + Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text), + None => Some(st), + } + } + + /// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision). + pub fn text_story_mut(&mut self, story: StoryId, cell: Option) -> Option<&mut Story> { + let st = self.story_mut(story)?; + match cell { + Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text), + Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text), + None => Some(st), + } + } +} + +impl Story { + /// Visit this story and every text inside it (table cells, footnotes), mutably. + pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) { + f(self); + for t in self.tables.values_mut() { + for c in &mut std::sync::Arc::make_mut(t).cells { + c.text.for_each_text_mut(f); + } + } + for n in &mut self.notes { + std::sync::Arc::make_mut(n).text.for_each_text_mut(f); + } + } + + /// The table whose anchor paragraph is `pi`. + pub fn para_table(&self, pi: usize) -> Option<&Arc
> { + self.tables.get(&self.paras.get(pi)?.table?) + } + + /// Byte offset of a table's anchor character. + pub fn table_anchor(&self, id: u64) -> Option { + let pi = self.paras.iter().position(|p| p.table == Some(id))?; + let r = self.para_ranges().get(pi)?.clone(); + self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k) + } + + /// The table anchored at or right before byte `pos` (a caret next to the anchor). + pub fn table_at(&self, pos: usize) -> Option { + let pi = self.para_at(pos); + self.paras.get(pi)?.table + } + + /// Mutable table (copy-on-write); bumps the story revision. + pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> { + let t = self.tables.get_mut(&id)?; + self.rev = self.rev.wrapping_add(1); + Some(Arc::make_mut(t)) + } + + /// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset. + pub fn insert_table(&mut self, pos: usize, table: Table) -> usize { + let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len())); + let pi = self.para_at(pos); + let pstart = self.para_ranges()[pi].start; + let mut s = String::new(); + if pos > pstart { + s.push('\n'); + } + let anchor = pos + s.len(); + s.push(TABLE_ANCHOR); + if !self.text[pos..].starts_with('\n') { + s.push('\n'); + } + let id = table.id; + self.tables.insert(id, Arc::new(table)); + // Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it. + self.insert_raw(pos, &s); + let api = self.para_at(anchor); + self.paras[api].table = Some(id); + self.fix_tables(); + self.rev = self.rev.wrapping_add(1); + anchor + } + + /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it + /// still contains an anchor character, every table is referenced once, and tables nobody + /// references are dropped. An anchor that shares a table's paragraph with another anchor gets + /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. + pub(crate) fn fix_tables(&mut self) { + self.fix_tables_with(&[]); + } + + /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are + /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph + /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other + /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before + /// the fix-up, where it inserted paragraph returns. + pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { + if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { + return Vec::new(); + } + let mut owner = std::collections::BTreeMap::new(); + let mut used = std::collections::HashSet::new(); + for &(at, id) in claims { + if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) + && self.tables.contains_key(&id) + && !owner.contains_key(&at) + && used.insert(id) + { + owner.insert(at, id); + } + } + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter().zip(&ranges) { + let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; + let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); + if let Some(at) = free { + used.insert(id); + owner.insert(at, id); + } + } + // A paragraph holding a table's anchor and other anchors is split before each later anchor. + let mut splits = Vec::new(); + for r in &ranges { + let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); + if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { + splits.extend(anchors.into_iter().skip(1)); + } + } + for &at in splits.iter().rev() { + self.insert_raw(at, "\n"); + } + // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. + let owner: std::collections::BTreeMap = + owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter_mut().zip(ranges) { + p.table = owner.range(r).next().map(|(_, &id)| id); + } + self.tables.retain(|id, _| used.contains(id)); + splits + } + + /// Check table invariants (part of [`Story::check`]). + pub(crate) fn check_tables(&self) -> Result<(), String> { + let ranges = self.para_ranges(); + let mut used = std::collections::HashSet::new(); + for (pi, p) in self.paras.iter().enumerate() { + let Some(id) = p.table else { continue }; + let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?; + if t.id != id { + return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id)); + } + if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 { + return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0)); + } + if !used.insert(id) { + return Err(format!("story {}: table {id} anchored twice", self.id.0)); + } + t.check()?; + } + if used.len() != self.tables.len() { + return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len())); + } + Ok(()) + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge.rs similarity index 100% rename from crates/engine/src/cmd/datamerge/mod.rs rename to crates/engine/src/cmd/datamerge.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a10e9ae2f6..890974f325 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -550,343 +550,7 @@ fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, pa } #[cfg(test)] -mod text_clipboard_tests { - use super::*; - - fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId { - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story - } - - #[test] - fn paste_text_with_no_insertion_point_makes_a_text_frame() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); - // Text from another app, nothing copied here: a new, selected text frame. - let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); - let id = ItemId(r["id"].as_u64().unwrap()); - let d = s.doc().unwrap(); - assert_eq!(d.selection.items, vec![id]); - assert!(d.selection.text.is_none(), "selected as an object"); - let it = d.doc.item(id).unwrap(); - assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); - let page = &d.doc.spreads[0].pages[0]; - let (b, m) = (it.bounds(), page.margin_rect()); - assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); - assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); - assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); - assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(items(&s), 0, "one undo removes it"); - // Centred on `at`; hostile numbers are clamped. - let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); - let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); - assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); - assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); - s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); - // Copied objects paste when no text is given. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let n = items(&s); - let r = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(r["ids"].as_array().unwrap().len(), 1); - assert_eq!(items(&s), n + 1); - assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); - s.doc().unwrap().doc.check().unwrap(); - } - - /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). - #[test] - fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let story = frame(&mut s, "ab"); - let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let marker = copied_objects_text(&s); - s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); - assert!(r.get("anchored").is_none(), "{r}"); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); - // What the object copy put on the system clipboard still pastes the objects. - assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); - // With no insertion point too: the rectangle, not a text frame holding the marker. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); - let id = ItemId(r["ids"][0].as_u64().unwrap()); - assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); - s.doc().unwrap().doc.check().unwrap(); - // Paste without Formatting inserts the text given; empty text is no text. - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); - assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); - } - - /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. - #[test] - fn copied_text_frames_paste_at_a_caret_as_their_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let target = frame(&mut s, "ab"); - let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { - s.execute("selection.set", &json!({"ids": ids})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("edit.paste", &json!({"text": text})).unwrap() - }; - let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); - // The same text copied in another app, and the system clipboard read back as absent. - let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); - assert!(r.get("anchored").is_none(), "{r}"); - s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); - // A group with a text frame inside, its own placeholder on the system clipboard. - let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); - let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); - let r = copy_and_paste(&mut s, json!([group]), Value::Null); - assert!(r.get("anchored").is_none(), "{r}"); - assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn outside_text_never_pastes_markers() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - // Private-use characters from another app would read as footnote, table or variable markers. - let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; - let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); - let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); - s.doc().unwrap().doc.check().unwrap(); - let b = frame(&mut s, "Target"); - s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); - s.execute("edit.paste", &json!({"text": dirty})).unwrap(); - s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); - assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "Bold words\nSecond"); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); - s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); - let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); - s.execute("edit.deselectAll", &json!({})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); - let d = s.doc().unwrap(); - let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); - assert_eq!(st.text, "Bold words\nSecond"); - assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); - assert_eq!(st.char_format_at(7).over.font_style, None); - assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); - d.doc.check().unwrap(); - } - - #[test] - fn formatted_copy_paste_and_plain_paste() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "Bold words here."); - let b = frame(&mut s, "Target: ."); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - // A footnote inside the copied range travels with it. - s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap(); - s.execute("footnote.insert", &json!({"text": "n"})).unwrap(); - let fl = designcraft_doc::FOOTNOTE_REF.len_utf8(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap(); - let r = s.execute("edit.copy", &json!({})).unwrap(); - assert_eq!(r["text"], "Bold words"); - // Formatted paste when the system clipboard matches. - s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap(); - s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap(); - let st = s.doc().unwrap().doc.story(b).unwrap().clone(); - assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF)); - assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold")); - assert_eq!(st.notes.len(), 1); - assert_eq!(st.notes[0].text.text, "n"); - // Other text on the system clipboard pastes as plain text. - s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap(); - assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste"); - // Paste without formatting takes the insertion format. - s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); - let st = s.doc().unwrap().doc.story(b).unwrap().clone(); - assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text); - assert_eq!(st.char_format_at(1).over.font_style, None); - // Cut removes and copies. - s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap(); - assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold"); - assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget")); - s.doc().unwrap().doc.check().unwrap(); - } - - fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let source = frame(&mut s, "aéz"); - s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); - s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); - s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); - s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); - s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); - assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); - (s, source) - } - - fn assert_child_note_paste( - s: &mut Session, - source: designcraft_doc::StoryId, - parent: designcraft_doc::StoryId, - cell: designcraft_doc::CellAddr, - endpoints: (usize, usize), - ) { - let before = s.doc().unwrap().doc.clone(); - let selected = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); - assert_eq!(selected.frame, None); - let before_child = before.text_story(parent, Some(cell)).unwrap(); - assert_eq!(before_child.text, "L----R"); - let clipboard = s.text_clipboard.clone(); - let history = s.execute("document.history", &json!({})).unwrap(); - assert_eq!(history["redo"], json!([])); - - let result = s.execute("edit.paste", &json!({})).unwrap(); - let after = s.doc().unwrap().doc.clone(); - let child = after.text_story(parent, Some(cell)).unwrap(); - assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); - assert_eq!(child.len(), 6); - assert_eq!(result, json!({"pos": 5, "formatted": true})); - assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); - assert!(child.endnotes.is_empty() && child.editorial.is_empty()); - for pos in [1, 2, 4] { - let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - assert_eq!(format.over.font_style.as_deref(), Some("Bold")); - } - // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. - for pos in [0, 5] { - let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - assert_eq!(new_format, old_format); - } - assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); - assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); - assert_eq!(after.story(source), before.story(source)); - assert_eq!(s.text_clipboard, clipboard); - - let mut pasted_history = history.clone(); - pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); - assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); - assert_eq!(s.doc().unwrap().doc, before); - let mut undone_history = history; - undone_history["redo"] = json!(["Paste"]); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); - // This filter does not change which selection Undo restores. - assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); - assert_eq!(s.doc().unwrap().doc, after); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); - } - - #[test] - fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { - let (mut s, source) = note_clipboard_session(); - let parent = frame(&mut s, "PZ"); - s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); - let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); - s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); - s.execute("table.nextCell", &json!({})).unwrap(); - s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); - s.execute("table.prevCell", &json!({})).unwrap(); - s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - for _ in 0..4 { - s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); - } - let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); - let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; - assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); - let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; - assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); - let mut expected_table = (*before_table).clone(); - expected_table.cells[0].text = table.cells[0].text.clone(); - assert_eq!(table.as_ref(), &expected_table); - - // The same clipboard must still retain both markers when pasted into an ordinary root story. - let root = frame(&mut s, "L----R"); - s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); - assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); - assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); - } - - #[test] - fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { - let (mut s, source) = note_clipboard_session(); - let parent = frame(&mut s, "PZ"); - let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); - let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.move", &json!({"dir": "left"})).unwrap(); - for _ in 0..4 { - s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); - } - let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); - assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); - let after = s.doc().unwrap().doc.story(parent).unwrap(); - assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); - assert_eq!(after.note(neighbor), before.note(neighbor)); - assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); - } - - #[test] - fn change_case_keeps_runs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "the quick brown fox. jumps over straße"); - s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap(); - s.execute("type.italic", &json!({})).unwrap(); - let len = s.doc().unwrap().doc.story(a).unwrap().len(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); - s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße"); - s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße"); - s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); - let st = s.doc().unwrap().doc.story(a).unwrap().clone(); - assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE"); - assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic")); - assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); - st.check().unwrap(); - } - - #[test] - fn change_case_leaves_georgian_without_initial_capitals() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); - let len = s.doc().unwrap().doc.story(a).unwrap().len(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); - // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. - s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); - s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); - s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); - } -} +mod text_clipboard_tests; fn paste_into(s: &mut Session, p: &Value) -> Result { use super::object::RemoveKeep; diff --git a/crates/engine/src/cmd/edit/text_clipboard_tests.rs b/crates/engine/src/cmd/edit/text_clipboard_tests.rs new file mode 100644 index 0000000000..d947105cd3 --- /dev/null +++ b/crates/engine/src/cmd/edit/text_clipboard_tests.rs @@ -0,0 +1,335 @@ +use super::*; + +fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId { + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story +} + +#[test] +fn paste_text_with_no_insertion_point_makes_a_text_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + // Text from another app, nothing copied here: a new, selected text frame. + let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let d = s.doc().unwrap(); + assert_eq!(d.selection.items, vec![id]); + assert!(d.selection.text.is_none(), "selected as an object"); + let it = d.doc.item(id).unwrap(); + assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); + let page = &d.doc.spreads[0].pages[0]; + let (b, m) = (it.bounds(), page.margin_rect()); + assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); + assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); + assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); + assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(items(&s), 0, "one undo removes it"); + // Centred on `at`; hostile numbers are clamped. + let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); + let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); + assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); + s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); + // Copied objects paste when no text is given. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let n = items(&s); + let r = s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(items(&s), n + 1); + assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); +} + +/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). +#[test] +fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let story = frame(&mut s, "ab"); + let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let marker = copied_objects_text(&s); + s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); + // What the object copy put on the system clipboard still pastes the objects. + assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); + // With no insertion point too: the rectangle, not a text frame holding the marker. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); + let id = ItemId(r["ids"][0].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + // Paste without Formatting inserts the text given; empty text is no text. + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); +} + +/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. +#[test] +fn copied_text_frames_paste_at_a_caret_as_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let target = frame(&mut s, "ab"); + let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.paste", &json!({"text": text})).unwrap() + }; + let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); + // The same text copied in another app, and the system clipboard read back as absent. + let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); + assert!(r.get("anchored").is_none(), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); + // A group with a text frame inside, its own placeholder on the system clipboard. + let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); + let r = copy_and_paste(&mut s, json!([group]), Value::Null); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn outside_text_never_pastes_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Private-use characters from another app would read as footnote, table or variable markers. + let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; + let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); + s.doc().unwrap().doc.check().unwrap(); + let b = frame(&mut s, "Target"); + s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); + s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); + assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words\nSecond"); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); + let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); + let d = s.doc().unwrap(); + let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.text, "Bold words\nSecond"); + assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.char_format_at(7).over.font_style, None); + assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); + d.doc.check().unwrap(); +} + +#[test] +fn formatted_copy_paste_and_plain_paste() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words here."); + let b = frame(&mut s, "Target: ."); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + // A footnote inside the copied range travels with it. + s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap(); + s.execute("footnote.insert", &json!({"text": "n"})).unwrap(); + let fl = designcraft_doc::FOOTNOTE_REF.len_utf8(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap(); + let r = s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(r["text"], "Bold words"); + // Formatted paste when the system clipboard matches. + s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap(); + s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap(); + let st = s.doc().unwrap().doc.story(b).unwrap().clone(); + assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF)); + assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.notes.len(), 1); + assert_eq!(st.notes[0].text.text, "n"); + // Other text on the system clipboard pastes as plain text. + s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap(); + assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste"); + // Paste without formatting takes the insertion format. + s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.story(b).unwrap().clone(); + assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text); + assert_eq!(st.char_format_at(1).over.font_style, None); + // Cut removes and copies. + s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap(); + assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold"); + assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget")); + s.doc().unwrap().doc.check().unwrap(); +} + +fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = frame(&mut s, "aéz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); + s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); + (s, source) +} + +fn assert_child_note_paste( + s: &mut Session, + source: designcraft_doc::StoryId, + parent: designcraft_doc::StoryId, + cell: designcraft_doc::CellAddr, + endpoints: (usize, usize), +) { + let before = s.doc().unwrap().doc.clone(); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); + assert_eq!(selected.frame, None); + let before_child = before.text_story(parent, Some(cell)).unwrap(); + assert_eq!(before_child.text, "L----R"); + let clipboard = s.text_clipboard.clone(); + let history = s.execute("document.history", &json!({})).unwrap(); + assert_eq!(history["redo"], json!([])); + + let result = s.execute("edit.paste", &json!({})).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let child = after.text_story(parent, Some(cell)).unwrap(); + assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); + assert_eq!(child.len(), 6); + assert_eq!(result, json!({"pos": 5, "formatted": true})); + assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); + assert!(child.endnotes.is_empty() && child.editorial.is_empty()); + for pos in [1, 2, 4] { + let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(format.over.font_style.as_deref(), Some("Bold")); + } + // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. + for pos in [0, 5] { + let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(new_format, old_format); + } + assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); + assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); + assert_eq!(after.story(source), before.story(source)); + assert_eq!(s.text_clipboard, clipboard); + + let mut pasted_history = history.clone(); + pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, before); + let mut undone_history = history; + undone_history["redo"] = json!(["Paste"]); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); + // This filter does not change which selection Undo restores. + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); +} + +#[test] +fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); + s.execute("table.prevCell", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); + let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; + assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); + let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; + assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); + let mut expected_table = (*before_table).clone(); + expected_table.cells[0].text = table.cells[0].text.clone(); + assert_eq!(table.as_ref(), &expected_table); + + // The same clipboard must still retain both markers when pasted into an ordinary root story. + let root = frame(&mut s, "L----R"); + s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); + assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); + assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); +} + +#[test] +fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + } + let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); + assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); + let after = s.doc().unwrap().doc.story(parent).unwrap(); + assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); + assert_eq!(after.note(neighbor), before.note(neighbor)); + assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); +} + +#[test] +fn change_case_keeps_runs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "the quick brown fox. jumps over straße"); + s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap(); + s.execute("type.italic", &json!({})).unwrap(); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + let st = s.doc().unwrap().doc.story(a).unwrap().clone(); + assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE"); + assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); + st.check().unwrap(); +} + +#[test] +fn change_case_leaves_georgian_without_initial_capitals() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); +} diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 2dad2d820c..a4ee73ce49 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -7,1921 +7,17 @@ use std::{borrow::Cow, sync::Arc}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{EngineError, Result, Session}; -pub fn specs() -> Vec { - vec![ - cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, - "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised; with standard x1a, the resolution complex transparent regions are rasterized at: high 300, medium 150, low 72, default 300), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"x1a\"|\"a2b\" (x1a: PDF/X-1a:2003, the PDF/X-4 file flattened in its inks by craft-prepress: no transparency, only CMYK, gray and spot color), compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})", - has_doc, export_pdf), - cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", - has_doc, export_epub), - cmd!( - "document.epubSplit", - "EPUB Split Document", - [], - None, - "{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}", - has_doc, - |s, p| { - const ID: &str = "document.epubSplit"; - let cur = s.doc()?.doc.settings.epub_split.clone(); - let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) }; - let split = epub_split_param(ID, &s.doc()?.doc, v)?; - if split != cur { - let stored = split.clone(); - s.edit(move |d, _| { - d.settings.epub_split = stored; - Ok(Value::Null) - })?; - } - Ok(json!({"split": split})) - } - ), - cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, - "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", - has_doc, |s, p| { - export_epub(s, &super::with_param(p, "fixedLayout", serde_json::json!(true))) - }), - cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None, - "{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})", - has_doc, print_booklet), - cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, - "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes, warnings} (no path: {text, bytes, warnings})", - has_doc, export_html), - cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, - "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", - has_doc, export_pptx), - cmd!(noundo "file.exportText", "Export Text…", ["File"], None, - "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", - has_story_target, export_text), - ] -} - -fn print_booklet(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.printBooklet"; - let kind = match str_param(p, "type").unwrap_or("saddleStitch") { - "saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch, - "twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive, - t => { - return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))); - } - }; - let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None }; - let st = s.doc()?; - let r = designcraft_pdf::export_booklet(&super::interchange::with_pdf_boxes(&st.doc), &s.cache, &opts) - .map_err(|e| EngineError::Other(e.to_string()))?; - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), - } -} - -/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact. -fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec) { - // Match the exporter's reading order (including Article exclusions), not every asset - // in the document: unused or hidden graphics must not prevent an export. - let mut used = std::collections::BTreeSet::new(); - for block in designcraft_epub::reading_order(d) { - let designcraft_epub::Block::Image(id) = block else { - continue; - }; - let Some(it) = d.item(id) else { continue }; - let designcraft_doc::Content::Graphic(g) = &it.content else { - continue; - }; - used.insert(g.asset); - if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some()) - && let Some(poster) = it.media.as_ref().and_then(|m| m.poster) - { - used.insert(poster); - } - } - let mut out = Cow::Borrowed(d); - let mut warnings = Vec::new(); - for id in used { - let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { - continue; - }; - let png = designcraft_images::eps_png(&a.data); - if png.is_none() { - warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name)); - } - if let Some(a) = out.to_mut().assets.get_mut(&id) { - let a = Arc::make_mut(a); - a.mime = "image/png".into(); - a.data = Arc::new(png.unwrap_or_default()); - } - } - (out, warnings) -} - -fn export_html(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = designcraft_epub::HtmlOptions { - title: str_param(p, "title").map(str::to_string), - language: str_param(p, "language").map(str::to_string), - generic_families: generic_families(&st.doc), - }; - let (doc, warnings) = eps_previews(&st.doc); - let text = designcraft_epub::export_html(&doc, &opts); - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings})) - } - None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})), - } -} - -fn export_pptx(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.exportPptx"; - let st = s.doc()?; - let line_breaks = match str_param(p, "lineBreaks") { - None | Some("keep") => designcraft_pptx::LineBreaks::Keep, - Some("reflow") => designcraft_pptx::LineBreaks::Reflow, - Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), - }; - let opts = designcraft_pptx::PptxOptions { - pages: pages_param(ID, p, st.doc.page_count())?, - line_breaks, - title: str_param(p, "title").map(str::to_string), - author: str_param(p, "author").map(str::to_string), - created: None, - embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), - }; - // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame - // stays text: a picture of it would drop the rest of its story). - let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); - let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| { - !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) - }); - let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); - let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - if rasterized > 0 { - r.warnings.push(format!("{rasterized} object(s) went in as images")); - } - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), - } -} - -fn has_story_target(s: &Session) -> std::result::Result<(), String> { - super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) -} - -fn export_text(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.exportText"; - let sid = super::text::story_of(s, p).ok_or_else(|| bad(ID, "edit text or select a text frame"))?; - let doc = &s.doc()?.doc; - let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?; - let path = str_param(p, "path"); - let format = match str_param(p, "format") { - Some(f @ ("rtf" | "tagged")) => f, - Some("txt") | Some("text") => "txt", - Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))), - None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf", - None => "txt", - }; - let text = match format { - "rtf" => designcraft_textimport::export::rtf(doc, story), - "tagged" => designcraft_textimport::tagged::tagged_text(doc, story), - _ => designcraft_textimport::export::plain_text(story), - }; - match path { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": text.len()})) - } - None => Ok(json!({"bytes": text.len(), "text": text})), - } -} - -/// Text of the frames on page `abs`, in line order (for fixed-layout pages). -fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String { - let mut out = String::new(); - for st in d.stories.values() { - let cs = cache.get(d, st.id, None); - for ft in &cs.frames { - if d.page_of_item(ft.frame) != Some(abs) { - continue; - } - for l in &ft.lines { - let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); - let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); - if !t.trim().is_empty() { - out.push_str(t.trim_end_matches('\n')); - out.push('\n'); - } - } - } - } - out -} - -/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' -/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. -fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { - use designcraft_fonts::FontClass; - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut out = std::collections::BTreeMap::new(); - for (family, _) in designcraft_epub::stylesheet_fonts(d) { - if out.contains_key(&family) { - continue; - } - let generic = match db.family_kind(&family).map(|(k, _)| k.class) { - Some(FontClass::Serif | FontClass::SlabSerif) => "serif", - Some(FontClass::SansSerif) => "sans-serif", - Some(FontClass::Monospaced) => "monospace", - Some(FontClass::Script) => "cursive", - Some(FontClass::Decorative) => "fantasy", - Some(FontClass::Symbol | FontClass::Other) | None => continue, - }; - out.insert(family, generic.to_string()); - } - out -} - -/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out -/// (not available, a licence that forbids embedding, or part of a font collection). -fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut out = Vec::new(); - for (family, style) in designcraft_epub::stylesheet_fonts(d) { - let face = db.face(&family, &style); - if !face.family.eq_ignore_ascii_case(&family) { - warnings.push(format!("font `{family} {style}` is not available; not embedded")); - } else if face.restricted_licence() || face.bitmap_only() { - warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); - } else if face.data().starts_with(b"ttcf") { - warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); - } else { - out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); - } - } - out -} - -/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph -/// style the document has. -pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result { - let split: designcraft_doc::EpubSplit = - serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?; - if let designcraft_doc::EpubSplit::Style(name) = &split - && doc.styles.para(name).is_none() - { - return Err(bad(id, format!("no paragraph style `{name}`"))); - } - Ok(split) -} - -fn export_epub(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?; - // Object Export Options › Rasterize: those objects go in as images. - let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it)); - let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); - let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false); - let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let mut warnings: Vec = Vec::new(); - // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. - let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { - Some(path) => Some(super::datamerge::read_path_capped(path, super::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?), - None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), - }; - if let Some(c) = &cover - && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) - { - return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); - } - let opts = designcraft_epub::EpubOptions { - title: p.get("title").and_then(Value::as_str).map(str::to_string), - author: p.get("author").and_then(Value::as_str).map(str::to_string), - language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), - identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), - cover, - toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), - split, - generic_families: generic_families(&st.doc), - fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { - embedded_fonts(&st.doc, &mut warnings) - } else { - Vec::new() - }, - // The export time; on the web the document's own save time stands in. - #[cfg(not(target_arch = "wasm32"))] - modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), - #[cfg(target_arch = "wasm32")] - modified: None, - }; - if fixed { - let d = &st.doc; - let mut pages = Vec::new(); - for i in 0..d.page_count() { - let Some((si, pi)) = d.page_loc(i) else { - continue; - }; - let b = d.spreads[si].pages[pi].bounds(); - let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { - continue; - }; - pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) }); - } - let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; - return match p.get("path").and_then(Value::as_str) { - Some(path) => { - write_file(path, &bytes)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()})) - } - None => Ok(serde_json::json!({"base64": super::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), - }; - } - let (epub_doc, eps_warnings) = eps_previews(epub_doc); - warnings.extend(eps_warnings); - let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; - warnings.extend( - designcraft_epub::images_without_alt(&epub_doc) - .into_iter() - .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")), - ); - match p.get("path").and_then(Value::as_str) { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) - } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), - } -} - -/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. -fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { - Ok(match p.get("pages") { - None | Some(Value::Null) => None, - Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), - Some(Value::Array(a)) => { - let mut v = Vec::new(); - for x in a { - let n = x - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .filter(|n| *n >= 1 && *n <= page_count) - .ok_or_else(|| bad(id, format!("bad page {x}")))?; - // A page named twice is one page, as `"2,2"` already is: the list says which - // pages to export, not how many times. `[2, 2]` exported two copies of page 2 - // while `"2,2"` exported one. - if !v.contains(&(n - 1)) { - v.push(n - 1); - } - } - Some(v) - } - Some(Value::Number(n)) => { - let n = n - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .filter(|n| *n >= 1 && *n <= page_count) - .ok_or_else(|| bad(id, format!("bad page {n}")))?; - Some(vec![n - 1]) - } - Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), - }) -} - -pub(crate) fn options(p: &Value, page_count: usize) -> Result { - const ID: &str = "file.exportPdf"; - - let pages = pages_param(ID, p, page_count)?; - let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); - let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); - let marks = match p.get("marks") { - Some(Value::Bool(true)) => Marks::ALL, - Some(Value::Object(m)) => { - let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - Marks { - crop: b("crop"), - bleed: b("bleed"), - page_info: b("pageInfo"), - weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25), - offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0), - } - } - _ => Marks::NONE, - }; - let standard = match str_param(p, "standard") { - Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, x1a, a2b)")))?, - None => Standard::None, - }; - let created = match p.get("created") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?), - }; - Ok(PdfOptions { - pages, - spreads, - bleed, - marks, - standard, - compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), - title: str_param(p, "title").map(str::to_string), - author: str_param(p, "author").map(str::to_string), - tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), - media: p.get("media").and_then(Value::as_bool).unwrap_or(false), - created, - ..PdfOptions::default() - }) -} - -/// Does this object (or anything in it) need the transparency flattener? -fn transparent(it: &designcraft_doc::Item) -> bool { - it.involves_transparency() -} - -/// Transparency Flattener: each object involving transparency becomes an opaque image of its -/// area as it looks over what's beneath it (so the rest stays vector); spreads whose parent items -/// involve transparency are rasterised whole. Works on a copy of the document made for export. -/// Returns the copy and the number of rasterised regions. -fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, ppi: f64) -> (designcraft_doc::Document, usize) { - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; - let mut out = d.clone(); - let mut n = 0; - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi / 72.0; - let parent_transparent = |pid: Option| { - pid.and_then(|id| d.parent_index(id)) - .is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|p| p.items.iter().any(|it| transparent(it)))) - }; - // An opaque image item showing `img` over `r`. - let image_item = - |out: &mut designcraft_doc::Document, r: designcraft_geom::Rect, img: designcraft_render::Rendered, layer: designcraft_doc::LayerId| { - let (w, h) = (img.width, img.height); - let aid = AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(Asset { - id: aid, - name: format!("flattened-{}.png", aid.0), - mime: "image/png".into(), - data: std::sync::Arc::new(img.to_png()), - pixels: Some((w, h)), - ..Default::default() - }), - ); - let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); - it.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (w as f64, h as f64), - xf: designcraft_geom::Affine::translate((r.x0, r.y0)) - * designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64), - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - it.stroke.weight = 0.0; - it - }; - for (si, sp) in d.spreads.iter().enumerate() { - let whole = sp.pages.iter().any(|pg| pg.show_parent_items && parent_transparent(pg.parent)); - if whole { - // Parent items involve transparency: every page of the spread as one image. - let first = d.first_page_of_spread(si); - let lid = out.default_layer(); - let mut images = Vec::new(); - for (pi, pg) in sp.pages.iter().enumerate() { - if let Some(img) = rr.render_page(d, cache, first + pi, k, false, &Default::default()) { - images.push((pg.bounds(), img)); - } - } - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - osp.items.clear(); - for pg in &mut osp.pages { - pg.show_parent_items = false; - } - for (b, img) in images { - let it = image_item(&mut out, b, img, lid); - let _ = out.insert_item(SpreadRef::Doc(si), it, None); - n += 1; - } - continue; - } - // Region by region, bottom to top. - for (idx, it) in sp.items.iter().enumerate() { - if it.hidden || !transparent(it) { - continue; - } - let reach = it.bounds().inflate( - designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, - designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, - ); - // Only the part on the spread's pages and pasteboard that prints. - let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); - if w as u64 * h as u64 > 80_000_000 { - continue; - } - // The stack up to and including this object. - let mut below = d.clone(); - std::sync::Arc::make_mut(&mut below.spreads[si]).items.truncate(idx + 1); - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let img = rr.render( - &below, - cache, - &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], - w, - h, - view, - &opts, - ); - let new = image_item(&mut out, reach, img, it.layer); - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { - continue; - }; - if threaded(d, it) { - // Keep the frame (its story flows on) under the image. - osp.items.insert(pos + 1, std::sync::Arc::new(new)); - } else { - osp.items[pos] = std::sync::Arc::new(new); - } - n += 1; - } - } - (out, n) -} - -/// A text frame in a story that runs through other frames (replacing it would reflow the story). -fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { - it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1) -} - -/// Soft effects other than the gradient feather (which PDF export draws itself). -fn raster_effects(it: &designcraft_doc::Item) -> bool { - let e = &it.effects; - e.drop_shadow.on - || e.feather > 0.0 - || e.inner_shadow.on - || e.outer_glow.on - || e.inner_glow.on - || e.bevel.on - || e.satin.on - || e.directional_feather.on -} - -/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole -/// appearance (transparent around them, so they composite over what's behind). Objects in `skip` -/// stay as they are. -fn rasterize_effects( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - skip: &std::collections::HashMap, -) -> (Option, usize) { - rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id)) -} - -/// A drop shadow is the object's only soft effect, and the PDF writer draws its shadow itself -/// (a leaf object, not a group or a frame with items pasted into it). -fn shadow_only(it: &designcraft_doc::Item) -> bool { - let e = &it.effects; - e.drop_shadow.on - && !(e.feather > 0.0 - || e.inner_shadow.on - || e.outer_glow.on - || e.inner_glow.on - || e.bevel.on - || e.satin.on - || e.directional_feather.on - || e.gradient_feather.on) - && it.children().is_empty() - && !it.has_nested_items() -} - -/// For PDF/X-1a: the coverage of each spread object's soft drop shadow at `ppi`, so the PDF draws -/// the shadow in its own swatch's inks through a mask (which craft-prepress flattens per ink) -/// instead of an RGB image of the object. Objects whose only soft effect is the drop shadow. -/// -/// The coverage comes from the object drawn alone, at full opacity and Normal blending, with its -/// shadow and without it ([`shadow_coverage`]): the shadow's own alpha, its opacity included. The -/// object's own opacity and blend mode stay on the object's group in the PDF. -fn shadow_masks( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - ppi: f64, -) -> std::collections::HashMap { - use designcraft_doc::SpreadRef; - let mut out = std::collections::HashMap::new(); - let todo: Vec<(usize, designcraft_doc::ItemId)> = d - .spreads - .iter() - .enumerate() - .flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && shadow_only(it)).map(move |it| (si, it.id))) - .collect(); - if todo.is_empty() { - return out; - } - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi.clamp(36.0, 1200.0) / 72.0; - for (si, id) in todo { - let Some(it) = d.item(id) else { continue }; - let grow = designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0; - let reach = it.bounds().inflate(grow, grow); - let (w, h) = ((reach.width() * k).ceil().max(1.0), (reach.height() * k).ceil().max(1.0)); - if !(w.is_finite() && h.is_finite()) || w * h > 60_000_000.0 { - continue; - } - let (w, h) = (w as u32, h as u32); - let mut alpha = |shadow: bool| { - let mut solo = d.clone(); - let Some(sp) = solo.spreads.get_mut(si) else { return Vec::new() }; - let sp = std::sync::Arc::make_mut(sp); - sp.items.retain(|x| x.id == id); - for pg in &mut sp.pages { - pg.show_parent_items = false; - } - if let Some(x) = sp.items.first_mut() { - let x = std::sync::Arc::make_mut(x); - x.opacity = 1.0; - x.blend = Default::default(); - x.effects.drop_shadow.on = shadow; - } - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; - let placed = [designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::IDENTITY }]; - rr.render(&solo, cache, &placed, w, h, view, &opts).pixels.as_chunks::<4>().0.iter().map(|px| px[3]).collect::>() - }; - let (with, without) = (alpha(true), alpha(false)); - let n = w as usize * h as usize; - if with.len() != n || without.len() != n { - continue; - } - let coverage: Vec = with.iter().zip(&without).map(|(a, b)| shadow_coverage(*a, *b)).collect(); - out.insert(id, designcraft_pdf::ShadowMask { rect: reach, width: w, height: h, coverage: std::sync::Arc::new(coverage) }); - } - out -} - -/// The shadow's alpha `s` at a pixel where the object alone has alpha `without` and the object -/// over its shadow `with`: `with = a + s(1 − a)` for the object's alpha `a`, so the PDF, which -/// draws the object over the shadow, gets the same pixel back. 0 where the object is opaque. -fn shadow_coverage(with: u8, without: u8) -> u8 { - let rest = 255 - u32::from(without); - if rest == 0 { - return 0; - } - let shown = u32::from(with.saturating_sub(without)); - u8::try_from(((shown * 255 + rest / 2) / rest).min(255)).unwrap_or(u8::MAX) -} - -/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at -/// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a -/// nonprinting [`wrap_stand_in`], so text wraps around it as before. -fn rasterize_where( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - ppi: f64, - keep_wrap: bool, - pred: impl Fn(&designcraft_doc::Item) -> bool, -) -> (Option, usize) { - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; - let todo: Vec<(usize, ItemId)> = - d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect(); - if todo.is_empty() { - return (None, 0); - } - let mut out = d.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi / 72.0; - let mut n = 0; - for (si, id) in todo { - let Some(it) = d.item(id) else { continue }; - let reach = it.bounds().inflate( - designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, - designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, - ); - // Just this object on its spread, no paper. - let mut solo = d.clone(); - let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]); - sp.items.retain(|x| x.id == id); - for pg in &mut sp.pages { - pg.show_parent_items = false; - } - let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); - if w as u64 * h as u64 > 60_000_000 { - continue; - } - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; - let img = rr.render( - &solo, - cache, - &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], - w, - h, - view, - &opts, - ); - let aid = AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(Asset { - id: aid, - name: format!("effects-{}.png", id.0), - mime: "image/png".into(), - data: std::sync::Arc::new(img.to_png()), - pixels: Some((w, h)), - ..Default::default() - }), - ); - let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); - img_item.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (w as f64, h as f64), - xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - img_item.stroke.weight = 0.0; - img_item.alt_text = it.alt_text.clone(); - img_item.export_options = it.export_options.clone(); - // Same place in the stacking order. - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - if let Some(pos) = osp.items.iter().position(|x| x.id == id) { - osp.items[pos] = std::sync::Arc::new(img_item); - if keep_wrap && has_wrap(it, 0) { - osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0))); - } - n += 1; - } - } - (Some(out), n) -} - -/// Whether `it`, its placed graphic or an item in it wraps text. -fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool { - use designcraft_doc::{Content, WrapMode}; - if depth > MAX_STAND_IN_DEPTH { - return false; - } - it.wrap.mode != WrapMode::None - || match &it.content { - Content::Graphic(g) => g.wrap.mode != WrapMode::None, - Content::Group { items } => items.iter().any(|c| has_wrap(c, depth + 1)), - _ => false, - } -} - -/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins. -const MAX_STAND_IN_DEPTH: usize = 64; - -/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and -/// nothing else that reaches the PDF: no text, form field, button or media. The image of the -/// object stands in for all of that, as it does without the copy. -fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item { - use designcraft_doc::Content; - let mut c = it.clone(); - c.nonprinting = true; - c.form_field = None; - c.button = None; - c.media = None; - c.content = match &it.content { - Content::Text(_) => Content::Unassigned, - Content::Group { items } if depth < MAX_STAND_IN_DEPTH => { - Content::Group { items: items.iter().map(|i| std::sync::Arc::new(wrap_stand_in(i, depth + 1))).collect() } - } - Content::Group { .. } => Content::Group { items: Vec::new() }, - other => other.clone(), - }; - c -} - -/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. -/// -/// Parent-page and anchored placements count too, including anchors inside table-cell, -/// footnote and endnote text. Only the export copy is rewritten. -fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { - let choices: Vec<_> = d - .placed_items() - .into_iter() - .filter(|it| it.has_pdf_layer_override()) - .filter_map(|it| { - let g = it.graphic()?; - let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?; - Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone())) - }) - .collect(); - if choices.is_empty() { - return Ok((None, 0)); - } - let mut out = d.clone(); - let mut images = std::collections::HashMap::new(); - let mut n = 0; - for (id, g, a, hidden) in choices { - // The whole page at 300 ppi (the graphic may show only a box of it). - let shown = designcraft_render::shown_box(&a); - let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); - let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t)); - let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32; - let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown) - .map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?; - let px = designcraft_render::image_size(&png); - let aid = designcraft_doc::AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(designcraft_doc::Asset { - id: aid, - name: format!("{}.png", a.name), - mime: "image/png".into(), - data: std::sync::Arc::new(png), - pixels: px, - ..Default::default() - }), - ); - images.insert(id, aid); - n += 1; - } - out.update_placed_items( - |it| images.contains_key(&it.id), - |it| { - if let designcraft_doc::Content::Graphic(gg) = &mut it.content - && let Some(aid) = images.get(&it.id) - { - gg.asset = *aid; - } - }, - ); - Ok((Some(out), n)) -} - -/// Object number of the first page of a PDF (its page tree's first kid). -fn first_page_ref(pdf: &[u8]) -> Option { - let s = String::from_utf8_lossy(pdf); - let i = s.find("/Kids[")? + 6; - s[i..].split_whitespace().next()?.parse().ok() -} - -fn export_pdf(s: &mut Session, p: &Value) -> Result { - let r = pdf_export(s, p)?; - match str_param(p, "path") { - Some(path) => { - write_file(path, &r.bytes)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), - } -} - -/// Transparency Flattener presets (`flatten`): High / Medium / Low Resolution, or a ppi. -pub(crate) fn flatten_ppi(id: &str, p: &Value) -> Result> { - Ok(match p.get("flatten") { - None | Some(Value::Null) | Some(Value::Bool(false)) => None, - Some(Value::String(s)) if s == "high" => Some(300.0), - Some(Value::String(s)) if s == "medium" => Some(150.0), - Some(Value::String(s)) if s == "low" => Some(72.0), - Some(Value::Bool(true)) => Some(300.0), - Some(v) => Some(v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad(id, "`flatten`: high|medium|low or 36–1200 ppi"))?), - }) -} - -/// The print PDF of `opts`' pages: the transparency flattener at `ppi` (if any), placed PDFs -/// with hidden layers and soft effects as images, the PDF boxes of placed graphics, the PDF -/// itself and its transparency blend space. Export PDF and File › Print add their view options -/// to it ([`pdf_export`]); Separations Preview separates it as it is. -pub(crate) fn print_pdf( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - opts: &PdfOptions, - ppi: Option, -) -> Result { - // PDF/X-1a: craft-prepress flattens the PDF in its inks; `ppi` sets its resolution. - let x1a = opts.standard == Standard::PdfX1a; - let mut opts = opts.clone(); - if x1a { - opts.flatten_dpi = ppi.unwrap_or(300.0) as f32; - } - let flattened = if x1a { None } else { ppi.map(|ppi| flatten(d, cache, ppi)) }; - let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0); - let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; - let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); - let shadows = if x1a { shadow_masks(base2, cache, f64::from(opts.flatten_dpi)) } else { Default::default() }; - let (fx_doc, fx_count) = rasterize_effects(base2, cache, &shadows); - opts.shadows = std::sync::Arc::new(shadows); - let opts = &opts; - let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); - let boxed = super::interchange::with_pdf_boxes(doc); - let doc: &designcraft_doc::Document = &boxed; - let mut r = designcraft_pdf::export_pdf_with_report(doc, cache, opts).map_err(|e| EngineError::Other(e.to_string()))?; - // Flattening and effect rasterization replace placements, so inspect the source too. - if d.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { - r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); - } - // Transparency Blend Space: the page group of pages with transparency. CMYK print output - // gets its CMYK page groups from the writer. - let spreads = designcraft_pdf::sheet_spreads(doc, opts); - let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); - if !designcraft_pdf::print_color(doc, opts) && blended.iter().any(|b| *b) { - match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { - Some(b) => r.bytes = b, - None => r.warnings.push("the transparency blend space couldn't be set".into()), - } - } - if fx_count > 0 { - r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) exported as images")); - } - if layer_count > 0 { - r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); - } - if let Some((_, n)) = &flattened - && *n > 0 - { - r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); - } - Ok(r) -} - -/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF -/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. -pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; - let ppi = flatten_ppi("file.exportPdf", p)?; - let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; - let doc: &designcraft_doc::Document = &st.doc; - let spreads = designcraft_pdf::sheet_spreads(doc, &opts); - // Interactive PDF view options. - let mut cat = String::new(); - if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/FullScreen"); - } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/UseOutlines"); - } - match str_param(p, "pageLayout") { - Some("single") => cat.push_str("/PageLayout/SinglePage"), - Some("continuous") => cat.push_str("/PageLayout/OneColumn"), - Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), - Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), - Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), - Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), - None => {} - } - if let Some(v) = str_param(p, "view") { - // The first page opens with this view (pages are objects in order, so refer by index 0). - let dest = match v { - "fitPage" => "/Fit", - "fitWidth" => "/FitH null", - "actual" => "/XYZ null null 1", - o => { - return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))); - } - }; - if let Some(first) = first_page_ref(&r.bytes) { - cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); - } - } - if !cat.is_empty() { - match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) { - Some(b) => r.bytes = b, - None => r.warnings.push("the PDF view options couldn't be set".into()), - } - } - // Flip pages every N seconds (presentation). - if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) { - let n = designcraft_pdf::sheet_spreads(doc, &opts).len(); - let dur: Vec> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); - match designcraft_pdf::add_page_entries(&r.bytes, &dur) { - Some(b) => r.bytes = b, - None => r.warnings.push("page timings couldn't be set".into()), - } - } - // Page transitions (each spread's first page holds them). - let trans: Vec> = - spreads.into_iter().map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)).collect(); - if trans.iter().any(Option::is_some) { - match designcraft_pdf::add_transitions(&r.bytes, &trans) { - Some(b) => r.bytes = b, - None => r.warnings.push("page transitions couldn't be added to this PDF".into()), - } - } - Ok(r) -} +mod flatten; +mod formats; +mod pdf; +mod registry; #[cfg(test)] -mod placed_layer_tests { - use std::sync::Arc; - - use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table}; - use designcraft_geom::Rect; - use serde_json::json; - - use crate::Session; - - fn two_layer_pdf() -> Vec { - let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; - let objs = [ - "<>>>>>" - .to_string(), - "<>".to_string(), - "<>>>/Contents 4 0 R>>".to_string(), - format!("<>\nstream\n{content}\nendstream", content.len()), - "<>".to_string(), - "<>".to_string(), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes()); - } - let x = out.len(); - let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); - for o in offs { - t.push_str(&format!("{o:010} 00000 n\r\n")); - } - t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); - out.extend_from_slice(t.as_bytes()); - out - } - - fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item { - match location { - "cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item, - "footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item, - "endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item, - "anchor" => &d.story(sid).unwrap().objects[0].item, - _ => d.item(id).unwrap(), - } - } - - #[test] - fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let result = s - .execute( - "file.place", - &json!({ - "base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", - }), - ) - .unwrap(); - let id = ItemId(result["id"].as_u64().unwrap()); - s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap(); - // Relinking to an ordinary image can leave legacy fields on the placement. - // They are not an actionable placed-PDF override and must not fail closed. - let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); - let aid = d.item(id).unwrap().graphic().unwrap().asset; - let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap()); - asset.mime = "image/png".into(); - asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png()); - let (copy, count) = super::rasterize_layered(d).unwrap(); - assert_eq!(count, 0); - assert!(copy.is_none()); - s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); - } - - fn check_placement(location: &str) { - for all_on in [false, true] { - check_choice(location, all_on); - } - } - - fn check_choice(location: &str, all_on: bool) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); - let placed = s - .execute( - "file.place", - &json!({ - "base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", - "x": 0, "y": 0, "width": 100, - "spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0} - }), - ) - .unwrap(); - let id = ItemId(placed["id"].as_u64().unwrap()); - let hidden = if all_on { vec![] } else { vec!["Blue"] }; - s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap(); - let sid = { - let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); - let layer = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); - if !matches!(location, "doc" | "parent") { - let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone(); - Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id); - let object = AnchoredObject { item, position: Default::default() }; - let story = d.story_mut(sid).unwrap(); - match location { - "cell" => { - let mut table = Table::new(1, 1, 1, 0, 0, 20.0); - table.cells[0].text.insert_object(0, object); - story.insert_table(0, table); - } - "footnote" => { - story.insert_note(0, "Note", Default::default()).unwrap(); - Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object); - } - "endnote" => { - story.insert_endnote(0, "Endnote", Default::default()).unwrap(); - Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object); - } - "anchor" => story.insert_object(0, object), - _ => assert!(matches!(location, "doc" | "parent")), - } - } - sid - }; - // Exercise the real export command for each location, not just the traversal helper. - let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); - assert!( - result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")), - "{location}: {}", - result["warnings"] - ); - // Check the rewritten placement, including its pixels. Counting a placement without - // updating the nested object would still export the file's default layer visibility. - let source = &s.doc().unwrap().doc; - let (copy, count) = super::rasterize_layered(source).unwrap(); - assert_eq!(count, 1, "{location}"); - let copy = copy.unwrap(); - let item = placement(©, location, id, sid); - let asset = ©.assets[&item.graphic().unwrap().asset]; - assert_eq!(asset.mime, "image/png", "{location}"); - let image = designcraft_render::images::decoded(&asset.data, 0); - let image = image.unwrap(); - let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4]; - assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}"); - // Export preparation must not change the open document's placement or source asset. - let original = placement(source, location, id, sid); - assert_eq!(original.pdf_hidden_layers, hidden); - assert!(original.pdf_layer_override); - assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf"); - } - - #[test] - fn layered_pdf_a_document_placement() { - check_placement("doc"); - } - #[test] - fn layered_pdf_a_parent_placement() { - check_placement("parent"); - } - #[test] - fn layered_pdf_a_story_anchor() { - check_placement("anchor"); - } - #[test] - fn layered_pdf_a_table_cell_anchor() { - check_placement("cell"); - } - #[test] - fn layered_pdf_a_footnote_anchor() { - check_placement("footnote"); - } - #[test] - fn layered_pdf_a_endnote_anchor() { - check_placement("endnote"); - } -} - +use flatten::{rasterize_effects, shadow_coverage}; #[cfg(not(target_arch = "wasm32"))] -fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - crate::io::write_file(path, bytes).map_err(EngineError::Other) -} -#[cfg(target_arch = "wasm32")] -fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes"))) -} - -#[cfg(test)] -mod text_tests { - use serde_json::json; - - use crate::Session; - - fn unzip(b64: &str) -> zip::ZipArchive>> { - zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() - } - - fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { - let mut s = String::new(); - std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); - s - } - - /// Split Document is stored with the document as one undo step, follows a renamed style, and - /// an export can override it without storing its own. - #[test] - fn epub_split_document_setting() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nText\nTwo\nMore"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); - for at in [0, 9] { - s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap(); - } - let files = |s: &mut Session, p: serde_json::Value| { - let mut z = unzip(s.execute("file.exportEpub", &p).unwrap()["base64"].as_str().unwrap()); - (1..).take_while(|n| z.by_name(&if *n == 1 { "OEBPS/content.xhtml".into() } else { format!("OEBPS/content{n}.xhtml") }).is_ok()).count() - }; - assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); - assert_eq!(files(&mut s, json!({})), 1, "one content file by default"); - assert!(s.execute("document.epubSplit", &json!({"split": {"style": "Nope"}})).is_err()); - assert!(s.execute("document.epubSplit", &json!({"split": "h1"})).is_err()); - assert!(s.execute("file.exportEpub", &json!({"split": {"style": "Nope"}})).is_err()); - assert_eq!(s.execute("document.epubSplit", &json!({"split": {"style": "Chapter"}})).unwrap()["split"], json!({"style": "Chapter"})); - assert_eq!(files(&mut s, json!({})), 2); - assert_eq!(files(&mut s, json!({"split": "off"})), 1); - assert_eq!(s.doc().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Style("Chapter".into()), "the override isn't stored"); - s.execute("style.paragraph.edit", &json!({"name": "Chapter", "rename": "Part"})).unwrap(); - assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], json!({"style": "Part"})); - assert_eq!(files(&mut s, json!({})), 2); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); - } - - #[test] - fn object_export_options() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - // A plain rectangle rasterised for EPUB, centred, after a page break. - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); - s.execute( - "object.exportOptions", - &json!({"ids": [r["id"]], "rasterize": true, "align": "center", "pageBreakBefore": true, "altText": "A dot"}), - ) - .unwrap(); - let e = s.execute("file.exportEpub", &json!({})).unwrap(); - let mut z = unzip(e["base64"].as_str().unwrap()); - let x = read(&mut z, "OEBPS/content.xhtml"); - assert!( - x.contains("
")); - assert!(z.by_name("OEBPS/images/cover.png").is_ok()); - let r = s.execute("file.exportFixedEpub", &json!({})).unwrap(); - assert_eq!(r["pages"], 2); - let mut z = unzip(r["base64"].as_str().unwrap()); - let opf = read(&mut z, "OEBPS/content.opf"); - assert!(opf.contains("rendition:layout\">pre-paginated") && opf.contains("page2.xhtml")); - let p1 = read(&mut z, "OEBPS/page1.xhtml"); - assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); - } - - /// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts - /// (and reports those it can't). - #[test] - fn epub_font_fallbacks_and_embedding() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}})) - .unwrap(); - s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); - let r = s.execute("file.exportEpub", &json!({})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let css = read(&mut z, "OEBPS/style.css"); - assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}"); - assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked"); - let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let css = read(&mut z, "OEBPS/style.css"); - assert!(css.contains("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}"); - let opf = read(&mut z, "OEBPS/content.opf"); - let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}")); - assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}"); - let warnings = r["warnings"].as_array().unwrap(); - assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}"); - let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(html.contains("\"Source Sans 3\", sans-serif;")); - } - - /// A font whose licence allows embedding bitmaps only (fsType bit 9) is not packaged whole, - /// and a Semibold style asks for the Semibold face's weight, not Bold's. - #[test] - fn epub_embedding_follows_bitmap_only_licences_and_weights() { - use designcraft_fonts::testing::{font_with, with_fs_type}; - const BITMAP: &str = "DocFont Epub Bitmap Only"; - for fs_type in [0x0200, 0x0208] { - designcraft_fonts::FontDb::global() - .add_font(with_fs_type(font_with(&format!("{BITMAP} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap()); - } - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - for fs_type in [0x0200, 0x0208] { - let family = format!("{BITMAP} {fs_type:04x}"); - s.execute("style.paragraph.create", &json!({"name": family, "chars": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap(); - } - s.execute("style.paragraph.create", &json!({"name": "Semi", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Semibold"}})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); - let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let css = read(&mut z, "OEBPS/style.css"); - assert!(!css.contains(&format!("@font-face {{ font-family: \"{BITMAP}")), "{css}"); - let warnings = r["warnings"].to_string(); - for fs_type in [0x0200, 0x0208] { - assert!(warnings.contains(&format!("{BITMAP} {fs_type:04x} Regular`: its licence doesn't allow embedding")), "{warnings}"); - } - let semi = css.lines().find(|l| l.contains(".semi {")).unwrap_or_default(); - assert!(semi.contains("font-weight: 600;"), "{css}"); - } - - /// #771, #773: a cover image file and an identifier through the command; the package's - /// modified time is the export's. - #[test] - fn epub_cover_image_file_and_identifier() { - let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let jpg = dir.join("cover.jpg"); - std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap(); - let bad = dir.join("cover.txt"); - std::fs::write(&bad, b"not an image").unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); - let r = s.execute("file.exportEpub", &json!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let opf = read(&mut z, "OEBPS/content.opf"); - assert!(opf.contains("urn:isbn:9780000000002"), "{opf}"); - assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}"); - assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}"); - assert!(z.by_name("OEBPS/images/cover.jpg").is_ok()); - assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err()); - std::fs::remove_dir_all(&dir).ok(); - } - - /// The color spaces of every image XObject in `pdf`, as the families and colorant names. - fn image_spaces(pdf: &[u8]) -> Vec { - use hayro_syntax::object::{Name, Object}; - let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap(); - file.objects() - .into_iter() - .filter_map(|o| o.into_stream()) - .filter(|s| s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image")) - .filter_map(|s| match s.dict().get::>(b"ColorSpace")? { - Object::Name(n) => Some(n.as_str().to_string()), - Object::Array(a) => { - let mut it = a.iter::>(); - let family = it.next()?.into_name()?.as_str().to_string(); - let names = match it.next() { - Some(Object::Array(n)) => n.iter::>().map(|n| n.as_str().to_string()).collect::>().join(" "), - Some(Object::Name(n)) => n.as_str().to_string(), - _ => String::new(), - }; - Some(format!("{family} {names}")) - } - _ => None, - }) - .collect() - } - - /// The (Black, Gold) samples of every flattened DeviceN image that has both colorants. - fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> { - use hayro_syntax::object::{Array, Name, Object}; - let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap(); - let mut out = Vec::new(); - for st in file.objects().into_iter().filter_map(|o| o.into_stream()) { - let d = st.dict(); - if !d.get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { - continue; - } - let Some(cs) = d.get::>(b"ColorSpace") else { continue }; - let mut it = cs.iter::>(); - if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") { - continue; - } - let Some(Object::Array(names)) = it.next() else { continue }; - let names: Vec = names.iter::>().map(|n| n.as_str().to_string()).collect(); - let (Some(k), Some(g)) = (names.iter().position(|n| n == "Black"), names.iter().position(|n| n == "Gold")) else { continue }; - let data = st.decoded().unwrap(); - out.extend(data.chunks(names.len()).filter(|px| px.len() == names.len()).map(|px| (px[k], px[g]))); - } - out - } - - /// The shadow coverage gives the rendered pixel back when the PDF draws the object over the - /// shadow, also under a partly transparent object (an anti-aliased edge, a soft-alpha image). - #[test] - fn shadow_coverage_composites_back() { - for a in (0..=255u32).step_by(15) { - for s in (0..=255u32).step_by(15) { - let over = |c: u32| a + (c * (255 - a) + 127) / 255; - let with = over(s) as u8; - let c = u32::from(super::shadow_coverage(with, a as u8)); - assert!(over(c).abs_diff(u32::from(with)) <= 1, "object {a}, shadow {s}: coverage {c}"); - } - } - assert_eq!(super::shadow_coverage(255, 255), 0); - assert_eq!(super::shadow_coverage(10, 20), 0); - } - - /// PDF/X-1a through craft-prepress: a soft drop shadow over a spot, a 50% object and a - /// knockout group leave no transparency and no RGB; the shadow is flattened in the page's - /// inks (Black over the spot), not exported as an RGB image of the object; text stays text and - /// overprint stays `/op`; `flatten` sets the resolution. - #[test] - fn x1a_export_flattens_in_inks() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap(); - let bg = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [bg], "swatch": "Gold"})).unwrap(); - let shadowed = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [shadowed], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); - s.execute("object.dropShadow", &json!({"ids": [shadowed], "on": true, "distance": 8, "size": 6})).unwrap(); - let half = s.execute("frame.create", &json!({"rect": [300, 72, 400, 172]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [half], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); - s.execute("object.opacity", &json!({"ids": [half], "opacity": 0.5})).unwrap(); - let a = s.execute("frame.create", &json!({"rect": [72, 300, 172, 400]})).unwrap()["id"].clone(); - let b = s.execute("frame.create", &json!({"rect": [122, 350, 222, 450]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [a, b], "swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); - s.execute("object.opacity", &json!({"ids": [a, b], "opacity": 0.5})).unwrap(); - let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].clone(); - s.execute("object.transparencyGroup", &json!({"ids": [g], "knockout": true})).unwrap(); - let op = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [op], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); - s.execute("object.attributes", &json!({"ids": [op], "overprintFill": true})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 500, 400, 560], "content": "text", "text": "Live text", "caret": false})).unwrap(); - - let r = s.execute("file.exportPdf", &json!({"standard": "x1a", "flatten": "low"})).unwrap(); - let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - let text = String::from_utf8_lossy(&pdf).to_string(); - assert_eq!(designcraft_pdf::check_pdfx1a(&pdf), Vec::::new()); - assert!(pdf.starts_with(b"%PDF-1.4") && text.contains("/GTS_PDFXVersion(PDF/X-1:2003)") && text.contains("/S/GTS_PDFX")); - assert!(text.contains("/GTS_PDFXConformance(PDF/X-1a:2003)"), "the conformance level"); - let warnings = r["warnings"].to_string(); - assert!(!warnings.contains("soft effects"), "the shadow isn't an RGB image of the object: {warnings}"); - assert!(warnings.contains("at 72 ppi"), "{warnings}"); - let spaces = image_spaces(&pdf); - assert!(spaces.iter().all(|cs| !cs.contains("RGB") && !cs.contains("ICC")), "{spaces:?}"); - assert!( - spaces.iter().any(|cs| cs.starts_with("DeviceN") && cs.contains("Black") && cs.contains("Gold")), - "the shadow over the spot: {spaces:?}" - ); - // The shadow multiplies: the spot stays at 100% where the shadow's black is. - let shadowed: Vec<(u8, u8)> = black_and_gold(&pdf).into_iter().filter(|(k, _)| *k > 100).collect(); - assert!(!shadowed.is_empty(), "no shadow pixels"); - assert!(shadowed.iter().all(|(_, gold)| *gold >= 250), "the spot under the shadow: {:?}", &shadowed[..shadowed.len().min(8)]); - assert!(text.contains("/op true") && text.contains("/OPM 1"), "overprint"); - assert!(text.contains("/FontFile"), "the text is still text"); - // The default resolution. - let r = s.execute("file.exportPdf", &json!({"standard": "x1a"})).unwrap(); - assert!(r["warnings"].to_string().contains("at 300 ppi"), "{}", r["warnings"]); - } - - #[test] - fn flattener_rasterises_spreads_with_transparency() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); - // Opaque: nothing to flatten. - let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); - assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("flattened"))); - s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); - // Text away from the transparency stays live text. - s.execute("frame.create", &json!({"rect": [72, 400, 400, 450], "content": "text", "text": "Still vector", "caret": false})).unwrap(); - let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); - let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - assert!(String::from_utf8_lossy(&pdf).contains("/FontFile"), "the text is still text"); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened: 1 ")), "{r}"); - assert_eq!(r["pages"], 3); - let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(3)); - // The document itself is untouched. - assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).is_some()); - assert!(s.execute("file.exportPdf", &json!({"flatten": 5})).is_err()); - // Interactive view options land in the catalog and on the pages. - let r = s.execute("file.exportPdf", &json!({"fullScreen": true, "pageLayout": "single", "view": "fitPage", "advanceSeconds": 5})).unwrap(); - let t = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); - assert!( - t.contains("/PageMode/FullScreen") && t.contains("/PageLayout/SinglePage") && t.contains("/OpenAction[") && t.contains("/Dur 5.00"), - "view options" - ); - assert!(s.execute("file.exportPdf", &json!({"pageLayout": "sideways"})).is_err()); - // Unflattened, the page blends in the document's blend space (CMYK for print). - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); - assert_eq!(text.matches("/S/Transparency/CS/DeviceCMYK").count(), 1, "only the page with transparency"); - } - - #[test] - fn objects_with_soft_effects_keep_their_text_wrap_in_pdf() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Words wrap around the objects on this page. ".repeat(60); - let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": text, "caret": false})).unwrap()["id"] - .as_u64() - .unwrap(); - // A feathered frame with a contour wrap of the frame itself. - let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap(); - s.execute("object.textWrap", &json!({"ids": [a], "mode": "contour", "contour": "sameAsClipping", "offset": 6})).unwrap(); - // A feathered frame whose placed graphic has a bounding-box wrap. - let png = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![90; 40 * 40 * 4] }.to_png(); - let b = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].as_u64().unwrap(); - s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": b})).unwrap(); - s.execute("object.textWrap", &json!({"ids": [b], "content": true, "mode": "boundingBox", "offset": 6})).unwrap(); - s.execute("object.feather", &json!({"ids": [a, b], "width": 14})).unwrap(); - - let d = &s.doc().unwrap().doc; - let sid = d.item(designcraft_doc::ItemId(t)).and_then(|it| it.text_frame()).unwrap().story; - let before = designcraft_compose::frame_specs(d, sid)[0].exclusions.clone(); - assert_eq!(before.len(), 2); - let (fx, n) = super::rasterize_effects(d, &s.cache, &Default::default()); - assert_eq!(n, 2); - let fx = fx.unwrap(); - assert_eq!(designcraft_compose::frame_specs(&fx, sid)[0].exclusions, before); - // The objects that keep the wrap don't print; their images do. - let sp = &fx.spreads[0]; - assert_eq!(sp.items.iter().filter(|it| it.nonprinting).count(), 2); - assert_eq!(sp.items.iter().filter(|it| !it.nonprinting && it.graphic().is_some()).count(), 2); - } - - #[test] - fn export_story_as_text_and_rtf() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nTwo"})).unwrap(); - assert!(s.execute("file.exportText", &json!({"frame": r["id"]})).unwrap()["text"] == "One\r\nTwo"); - let html = s.execute("file.exportHtml", &json!({})).unwrap(); - assert!(html["text"].as_str().unwrap().contains("One

")); - let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap(); - assert!(tagged["text"].as_str().unwrap().contains(" 1000); - let fonts = s.execute("font.list", &json!({})).unwrap(); - assert!(fonts.to_string().contains("Black"), "{fonts}"); - } -} - -#[cfg(test)] -mod booklet_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn booklet_imposes_pages_in_pairs() { - use designcraft_pdf::{BookletKind, booklet_pairs}; - assert_eq!(booklet_pairs(8, BookletKind::SaddleStitch), [(Some(7), Some(0)), (Some(1), Some(6)), (Some(5), Some(2)), (Some(3), Some(4))]); - assert_eq!(booklet_pairs(6, BookletKind::SaddleStitch)[0], (None, Some(0)), "padded with blanks"); - assert_eq!(booklet_pairs(3, BookletKind::TwoUpConsecutive), [(Some(0), Some(1)), (Some(2), None)]); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - let r = s.execute("file.printBooklet", &json!({"spaceBetween": 18})).unwrap(); - assert_eq!(r["sheets"], 2); - let pdf = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).into_owned(); - let mb = pdf.find("/MediaBox").map(|i| pdf[i..i + 40].to_string()).unwrap_or_default(); - assert!(mb.contains("1242"), "two 612 pt pages and an 18 pt gap: {mb}"); - assert!(s.execute("file.printBooklet", &json!({"type": "perfectBound"})).is_err()); - // Page 1's black box lands on the right half of the first sheet (page 4 is on the left). - let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); - let r = s.execute("file.printBooklet", &json!({})).unwrap(); - let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - let px = designcraft_render::decode_pixmap_page(&bytes, 0).expect("rasterised"); - let (w, h) = (px.width() as f64, px.height() as f64); - let at = |x: f64, y: f64| px.sample((x / 1224.0 * w) as u16, (y / 792.0 * h) as u16); - let (ink, blank) = (at(612.0 + 200.0, 200.0), at(200.0, 200.0)); - assert!(ink.a > 200 && ink.r < 60, "right half has page 1: {ink:?}"); - assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}"); - } -} - -#[cfg(test)] -mod placed_pdf_print_tests { - use serde_json::json; - - use crate::Session; - use crate::cmd::{base64_decode, base64_encode}; - - /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. - fn logo() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - base64_decode(r["base64"].as_str().unwrap()) - } - - fn page_with(pdf: &[u8]) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); - s - } - - /// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with - /// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)". - #[test] - fn pdf_17_placed_in_pdfx4() { - let logo = logo(); - assert!(logo.starts_with(b"%PDF-1.7")); - let mut s = page_with(&logo); - let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap(); - assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); - assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6")); - // PDF 2.0 can't simply be relabelled: the export fails and names the file. - let mut v20 = logo.clone(); - v20[5..8].copy_from_slice(b"2.0"); - let mut s = page_with(&v20); - let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); - assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); - } - - /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. - fn catalog_v17(header: &str) -> Vec { - let content = "0 0 0 1 k 0 0 200 200 re f"; - let mut pdf = format!("{header}\n").into_bytes(); - let mut offsets = Vec::new(); - for body in [ - "<>".to_owned(), - "<>".to_owned(), - "<>>>".to_owned(), - format!("<>\nstream\n{content}\nendstream", content.len()), - ] { - offsets.push(pdf.len()); - pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); - } - let xref = pdf.len(); - pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); - for offset in offsets { - pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); - } - pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - pdf - } - - /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export - /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). - #[test] - fn catalog_version_17_placed_in_pdfx4() { - for header in ["%PDF-1.7", "%PDF-1.4"] { - let pdf = catalog_v17(header); - assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); - let mut s = page_with(&pdf); - let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); - assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); - let out = base64_decode(r["base64"].as_str().unwrap()); - assert!(out.starts_with(b"%PDF-1.6")); - assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); - } - } -} - -#[cfg(test)] -mod print_group_tests { - use serde_json::json; - - use crate::Session; - use crate::cmd::{base64_decode, base64_encode}; - - /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. - fn logo() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - base64_decode(r["base64"].as_str().unwrap()) - } - - fn page_with(pdf: &[u8]) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); - s - } - - /// A placed PDF's transparency group blends in CMYK in a print document and in RGB in a - /// screen one. - #[test] - fn print_transparency_groups_blend_in_cmyk() { - let mut s = page_with(&logo()); - let text = |s: &mut Session| { - let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap(); - String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned() - }; - let t = text(&mut s); - // The placed page's group inherits the page group, which blends in CMYK. - assert!(t.contains("/Group<>"), "placed page group without a blending space"); - assert!(t.contains("/Group<>"), "CMYK page group"); - assert!(!t.contains("DeviceRGB"), "no RGB left"); - assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes())); - // The file still reads, with the same page. - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - let bytes = base64_decode(r["base64"].as_str().unwrap()); - assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1); - // An RGB blend space keeps RGB groups. - s.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); - let t = text(&mut s); - assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); - } -} - -#[cfg(test)] -mod pptx_tests { - use serde_json::json; - - use crate::Session; - - fn unzip(b64: &str) -> zip::ZipArchive>> { - zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() - } - - fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { - let mut s = String::new(); - std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); - s - } - - #[test] - fn sample_exports_editable_slides() { - let mut s = Session::new(); - s.execute("file.newSample", &json!({})).unwrap(); - let r = s.execute("file.exportPptx", &json!({})).unwrap(); - assert_eq!(r["slides"], 4); - let mut z = unzip(r["base64"].as_str().unwrap()); - // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. - assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); - assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); - let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); - assert!(text.contains("Notes on the Grid"), "{text}"); - assert!(read(&mut z, "ppt/presentation.xml").contains("")); - - let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); - assert_eq!(r["slides"], 2); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); - assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); - assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); - } - - #[test] - fn rasterize_option_places_a_picture() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); - let shapes = |s: &mut Session| { - let r = s.execute("file.exportPptx", &json!({})).unwrap(); - read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") - }; - assert!(shapes(&mut s).contains("prst=\"ellipse\"")); - s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); - let x = shapes(&mut s); - assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); - } -} - -#[cfg(test)] -mod page_list_tests { - use serde_json::{Value, json}; - - use crate::Session; - - fn doc(pages: u64) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap(); - s - } - - /// The same selection written two ways exported different files: `"2,2"` is one page, because - /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2. - /// The list says which pages to export, not how many times. - #[test] - fn a_page_named_twice_is_exported_once_either_way() { - let mut s = doc(4); - for (pages, want) in [ - (json!("2,2"), 1), - (json!([2, 2]), 1), - (json!([2, 2, 2]), 1), - (json!("2-2,2"), 1), - (json!([1, 2, 1]), 2), - (json!("1,2,1"), 2), - (json!([4, 1]), 2), - (json!("4,1"), 2), - (json!("all"), 4), - ] { - let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap(); - assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}"); - let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap(); - assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}"); - } - // The two forms now agree byte for byte, and the pages come out in the document's order - // whichever order the list names them in (which is how the export already behaved). - // A fixed creation time: exports a second apart would differ in their dates. - let mut file = |pages: Value| { - let r = s.execute("file.exportPdf", &json!({"pages": pages, "created": 0})).unwrap(); - r["base64"].as_str().unwrap_or_default().to_string() - }; - assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree"); - assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order"); - assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page"); - // A page that does not exist is still refused, and an empty list still exports nothing. - assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err()); - assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); - } -} +mod text_tests; diff --git a/crates/engine/src/cmd/export/flatten.rs b/crates/engine/src/cmd/export/flatten.rs new file mode 100644 index 0000000000..23059d8f48 --- /dev/null +++ b/crates/engine/src/cmd/export/flatten.rs @@ -0,0 +1,610 @@ +use super::*; + +/// Does this object (or anything in it) need the transparency flattener? +pub(super) fn transparent(it: &designcraft_doc::Item) -> bool { + it.involves_transparency() +} + +/// Transparency Flattener: each object involving transparency becomes an opaque image of its +/// area as it looks over what's beneath it (so the rest stays vector); spreads whose parent items +/// involve transparency are rasterised whole. Works on a copy of the document made for export. +/// Returns the copy and the number of rasterised regions. +pub(super) fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, ppi: f64) -> (designcraft_doc::Document, usize) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; + let mut out = d.clone(); + let mut n = 0; + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi / 72.0; + let parent_transparent = |pid: Option| { + pid.and_then(|id| d.parent_index(id)) + .is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|p| p.items.iter().any(|it| transparent(it)))) + }; + // An opaque image item showing `img` over `r`. + let image_item = + |out: &mut designcraft_doc::Document, r: designcraft_geom::Rect, img: designcraft_render::Rendered, layer: designcraft_doc::LayerId| { + let (w, h) = (img.width, img.height); + let aid = AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(Asset { + id: aid, + name: format!("flattened-{}.png", aid.0), + mime: "image/png".into(), + data: std::sync::Arc::new(img.to_png()), + pixels: Some((w, h)), + ..Default::default() + }), + ); + let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (w as f64, h as f64), + xf: designcraft_geom::Affine::translate((r.x0, r.y0)) + * designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + it.stroke.weight = 0.0; + it + }; + for (si, sp) in d.spreads.iter().enumerate() { + let whole = sp.pages.iter().any(|pg| pg.show_parent_items && parent_transparent(pg.parent)); + if whole { + // Parent items involve transparency: every page of the spread as one image. + let first = d.first_page_of_spread(si); + let lid = out.default_layer(); + let mut images = Vec::new(); + for (pi, pg) in sp.pages.iter().enumerate() { + if let Some(img) = rr.render_page(d, cache, first + pi, k, false, &Default::default()) { + images.push((pg.bounds(), img)); + } + } + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + osp.items.clear(); + for pg in &mut osp.pages { + pg.show_parent_items = false; + } + for (b, img) in images { + let it = image_item(&mut out, b, img, lid); + let _ = out.insert_item(SpreadRef::Doc(si), it, None); + n += 1; + } + continue; + } + // Region by region, bottom to top. + for (idx, it) in sp.items.iter().enumerate() { + if it.hidden || !transparent(it) { + continue; + } + let reach = it.bounds().inflate( + designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, + designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, + ); + // Only the part on the spread's pages and pasteboard that prints. + let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 80_000_000 { + continue; + } + // The stack up to and including this object. + let mut below = d.clone(); + std::sync::Arc::make_mut(&mut below.spreads[si]).items.truncate(idx + 1); + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let img = rr.render( + &below, + cache, + &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], + w, + h, + view, + &opts, + ); + let new = image_item(&mut out, reach, img, it.layer); + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { + continue; + }; + if threaded(d, it) { + // Keep the frame (its story flows on) under the image. + osp.items.insert(pos + 1, std::sync::Arc::new(new)); + } else { + osp.items[pos] = std::sync::Arc::new(new); + } + n += 1; + } + } + (out, n) +} + +/// A text frame in a story that runs through other frames (replacing it would reflow the story). +pub(super) fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { + it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1) +} + +/// Soft effects other than the gradient feather (which PDF export draws itself). +fn raster_effects(it: &designcraft_doc::Item) -> bool { + let e = &it.effects; + e.drop_shadow.on + || e.feather > 0.0 + || e.inner_shadow.on + || e.outer_glow.on + || e.inner_glow.on + || e.bevel.on + || e.satin.on + || e.directional_feather.on +} + +/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole +/// appearance (transparent around them, so they composite over what's behind). Objects in `skip` +/// stay as they are. +pub(super) fn rasterize_effects( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + skip: &std::collections::HashMap, +) -> (Option, usize) { + rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id)) +} + +/// A drop shadow is the object's only soft effect, and the PDF writer draws its shadow itself +/// (a leaf object, not a group or a frame with items pasted into it). +fn shadow_only(it: &designcraft_doc::Item) -> bool { + let e = &it.effects; + e.drop_shadow.on + && !(e.feather > 0.0 + || e.inner_shadow.on + || e.outer_glow.on + || e.inner_glow.on + || e.bevel.on + || e.satin.on + || e.directional_feather.on + || e.gradient_feather.on) + && it.children().is_empty() + && !it.has_nested_items() +} + +/// For PDF/X-1a: the coverage of each spread object's soft drop shadow at `ppi`, so the PDF draws +/// the shadow in its own swatch's inks through a mask (which craft-prepress flattens per ink) +/// instead of an RGB image of the object. Objects whose only soft effect is the drop shadow. +/// +/// The coverage comes from the object drawn alone, at full opacity and Normal blending, with its +/// shadow and without it ([`shadow_coverage`]): the shadow's own alpha, its opacity included. The +/// object's own opacity and blend mode stay on the object's group in the PDF. +pub(super) fn shadow_masks( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + ppi: f64, +) -> std::collections::HashMap { + use designcraft_doc::SpreadRef; + let mut out = std::collections::HashMap::new(); + let todo: Vec<(usize, designcraft_doc::ItemId)> = d + .spreads + .iter() + .enumerate() + .flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && shadow_only(it)).map(move |it| (si, it.id))) + .collect(); + if todo.is_empty() { + return out; + } + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi.clamp(36.0, 1200.0) / 72.0; + for (si, id) in todo { + let Some(it) = d.item(id) else { continue }; + let grow = designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0; + let reach = it.bounds().inflate(grow, grow); + let (w, h) = ((reach.width() * k).ceil().max(1.0), (reach.height() * k).ceil().max(1.0)); + if !(w.is_finite() && h.is_finite()) || w * h > 60_000_000.0 { + continue; + } + let (w, h) = (w as u32, h as u32); + let mut alpha = |shadow: bool| { + let mut solo = d.clone(); + let Some(sp) = solo.spreads.get_mut(si) else { return Vec::new() }; + let sp = std::sync::Arc::make_mut(sp); + sp.items.retain(|x| x.id == id); + for pg in &mut sp.pages { + pg.show_parent_items = false; + } + if let Some(x) = sp.items.first_mut() { + let x = std::sync::Arc::make_mut(x); + x.opacity = 1.0; + x.blend = Default::default(); + x.effects.drop_shadow.on = shadow; + } + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; + let placed = [designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::IDENTITY }]; + rr.render(&solo, cache, &placed, w, h, view, &opts).pixels.as_chunks::<4>().0.iter().map(|px| px[3]).collect::>() + }; + let (with, without) = (alpha(true), alpha(false)); + let n = w as usize * h as usize; + if with.len() != n || without.len() != n { + continue; + } + let coverage: Vec = with.iter().zip(&without).map(|(a, b)| shadow_coverage(*a, *b)).collect(); + out.insert(id, designcraft_pdf::ShadowMask { rect: reach, width: w, height: h, coverage: std::sync::Arc::new(coverage) }); + } + out +} + +/// The shadow's alpha `s` at a pixel where the object alone has alpha `without` and the object +/// over its shadow `with`: `with = a + s(1 − a)` for the object's alpha `a`, so the PDF, which +/// draws the object over the shadow, gets the same pixel back. 0 where the object is opaque. +pub(super) fn shadow_coverage(with: u8, without: u8) -> u8 { + let rest = 255 - u32::from(without); + if rest == 0 { + return 0; + } + let shown = u32::from(with.saturating_sub(without)); + u8::try_from(((shown * 255 + rest / 2) / rest).min(255)).unwrap_or(u8::MAX) +} + +/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at +/// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a +/// nonprinting [`wrap_stand_in`], so text wraps around it as before. +pub(super) fn rasterize_where( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + ppi: f64, + keep_wrap: bool, + pred: impl Fn(&designcraft_doc::Item) -> bool, +) -> (Option, usize) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; + let todo: Vec<(usize, ItemId)> = + d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect(); + if todo.is_empty() { + return (None, 0); + } + let mut out = d.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi / 72.0; + let mut n = 0; + for (si, id) in todo { + let Some(it) = d.item(id) else { continue }; + let reach = it.bounds().inflate( + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + ); + // Just this object on its spread, no paper. + let mut solo = d.clone(); + let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]); + sp.items.retain(|x| x.id == id); + for pg in &mut sp.pages { + pg.show_parent_items = false; + } + let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 60_000_000 { + continue; + } + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; + let img = rr.render( + &solo, + cache, + &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], + w, + h, + view, + &opts, + ); + let aid = AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(Asset { + id: aid, + name: format!("effects-{}.png", id.0), + mime: "image/png".into(), + data: std::sync::Arc::new(img.to_png()), + pixels: Some((w, h)), + ..Default::default() + }), + ); + let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); + img_item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (w as f64, h as f64), + xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + img_item.stroke.weight = 0.0; + img_item.alt_text = it.alt_text.clone(); + img_item.export_options = it.export_options.clone(); + // Same place in the stacking order. + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + if let Some(pos) = osp.items.iter().position(|x| x.id == id) { + osp.items[pos] = std::sync::Arc::new(img_item); + if keep_wrap && has_wrap(it, 0) { + osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0))); + } + n += 1; + } + } + (Some(out), n) +} + +/// Whether `it`, its placed graphic or an item in it wraps text. +fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool { + use designcraft_doc::{Content, WrapMode}; + if depth > MAX_STAND_IN_DEPTH { + return false; + } + it.wrap.mode != WrapMode::None + || match &it.content { + Content::Graphic(g) => g.wrap.mode != WrapMode::None, + Content::Group { items } => items.iter().any(|c| has_wrap(c, depth + 1)), + _ => false, + } +} + +/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins. +const MAX_STAND_IN_DEPTH: usize = 64; + +/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and +/// nothing else that reaches the PDF: no text, form field, button or media. The image of the +/// object stands in for all of that, as it does without the copy. +fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item { + use designcraft_doc::Content; + let mut c = it.clone(); + c.nonprinting = true; + c.form_field = None; + c.button = None; + c.media = None; + c.content = match &it.content { + Content::Text(_) => Content::Unassigned, + Content::Group { items } if depth < MAX_STAND_IN_DEPTH => { + Content::Group { items: items.iter().map(|i| std::sync::Arc::new(wrap_stand_in(i, depth + 1))).collect() } + } + Content::Group { .. } => Content::Group { items: Vec::new() }, + other => other.clone(), + }; + c +} + +/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. +/// +/// Parent-page and anchored placements count too, including anchors inside table-cell, +/// footnote and endnote text. Only the export copy is rewritten. +pub(super) fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { + let choices: Vec<_> = d + .placed_items() + .into_iter() + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let g = it.graphic()?; + let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone())) + }) + .collect(); + if choices.is_empty() { + return Ok((None, 0)); + } + let mut out = d.clone(); + let mut images = std::collections::HashMap::new(); + let mut n = 0; + for (id, g, a, hidden) in choices { + // The whole page at 300 ppi (the graphic may show only a box of it). + let shown = designcraft_render::shown_box(&a); + let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); + let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t)); + let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32; + let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown) + .map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?; + let px = designcraft_render::image_size(&png); + let aid = designcraft_doc::AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + id: aid, + name: format!("{}.png", a.name), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: px, + ..Default::default() + }), + ); + images.insert(id, aid); + n += 1; + } + out.update_placed_items( + |it| images.contains_key(&it.id), + |it| { + if let designcraft_doc::Content::Graphic(gg) = &mut it.content + && let Some(aid) = images.get(&it.id) + { + gg.asset = *aid; + } + }, + ); + Ok((Some(out), n)) +} + +#[cfg(test)] +mod placed_layer_tests { + use std::sync::Arc; + + use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table}; + use designcraft_geom::Rect; + use serde_json::json; + + use crate::Session; + + fn two_layer_pdf() -> Vec { + let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; + let objs = [ + "<>>>>>" + .to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".to_string(), + "<>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes()); + } + let x = out.len(); + let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); + for o in offs { + t.push_str(&format!("{o:010} 00000 n\r\n")); + } + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + out.extend_from_slice(t.as_bytes()); + out + } + + fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item { + match location { + "cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item, + "footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item, + "endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item, + "anchor" => &d.story(sid).unwrap().objects[0].item, + _ => d.item(id).unwrap(), + } + } + + #[test] + fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let result = s + .execute( + "file.place", + &json!({ + "base64": crate::cmd::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", + }), + ) + .unwrap(); + let id = ItemId(result["id"].as_u64().unwrap()); + s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap(); + // Relinking to an ordinary image can leave legacy fields on the placement. + // They are not an actionable placed-PDF override and must not fail closed. + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let aid = d.item(id).unwrap().graphic().unwrap().asset; + let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap()); + asset.mime = "image/png".into(); + asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png()); + let (copy, count) = super::rasterize_layered(d).unwrap(); + assert_eq!(count, 0); + assert!(copy.is_none()); + s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + } + + fn check_placement(location: &str) { + for all_on in [false, true] { + check_choice(location, all_on); + } + } + + fn check_choice(location: &str, all_on: bool) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let placed = s + .execute( + "file.place", + &json!({ + "base64": crate::cmd::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", + "x": 0, "y": 0, "width": 100, + "spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0} + }), + ) + .unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + let hidden = if all_on { vec![] } else { vec!["Blue"] }; + s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap(); + let sid = { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let layer = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); + if !matches!(location, "doc" | "parent") { + let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone(); + Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id); + let object = AnchoredObject { item, position: Default::default() }; + let story = d.story_mut(sid).unwrap(); + match location { + "cell" => { + let mut table = Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].text.insert_object(0, object); + story.insert_table(0, table); + } + "footnote" => { + story.insert_note(0, "Note", Default::default()).unwrap(); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object); + } + "endnote" => { + story.insert_endnote(0, "Endnote", Default::default()).unwrap(); + Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object); + } + "anchor" => story.insert_object(0, object), + _ => assert!(matches!(location, "doc" | "parent")), + } + } + sid + }; + // Exercise the real export command for each location, not just the traversal helper. + let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + assert!( + result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")), + "{location}: {}", + result["warnings"] + ); + // Check the rewritten placement, including its pixels. Counting a placement without + // updating the nested object would still export the file's default layer visibility. + let source = &s.doc().unwrap().doc; + let (copy, count) = super::rasterize_layered(source).unwrap(); + assert_eq!(count, 1, "{location}"); + let copy = copy.unwrap(); + let item = placement(©, location, id, sid); + let asset = ©.assets[&item.graphic().unwrap().asset]; + assert_eq!(asset.mime, "image/png", "{location}"); + let image = designcraft_render::images::decoded(&asset.data, 0); + let image = image.unwrap(); + let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4]; + assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}"); + // Export preparation must not change the open document's placement or source asset. + let original = placement(source, location, id, sid); + assert_eq!(original.pdf_hidden_layers, hidden); + assert!(original.pdf_layer_override); + assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf"); + } + + #[test] + fn layered_pdf_a_document_placement() { + check_placement("doc"); + } + #[test] + fn layered_pdf_a_parent_placement() { + check_placement("parent"); + } + #[test] + fn layered_pdf_a_story_anchor() { + check_placement("anchor"); + } + #[test] + fn layered_pdf_a_table_cell_anchor() { + check_placement("cell"); + } + #[test] + fn layered_pdf_a_footnote_anchor() { + check_placement("footnote"); + } + #[test] + fn layered_pdf_a_endnote_anchor() { + check_placement("endnote"); + } +} diff --git a/crates/engine/src/cmd/export/formats.rs b/crates/engine/src/cmd/export/formats.rs new file mode 100644 index 0000000000..025bcdac45 --- /dev/null +++ b/crates/engine/src/cmd/export/formats.rs @@ -0,0 +1,413 @@ +use super::flatten::{rasterize_where, threaded}; +use super::pdf::pages_param; +use super::*; + +pub(super) fn print_booklet(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.printBooklet"; + let kind = match str_param(p, "type").unwrap_or("saddleStitch") { + "saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch, + "twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive, + t => { + return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))); + } + }; + let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None }; + let st = s.doc()?; + let r = designcraft_pdf::export_booklet(&crate::cmd::interchange::with_pdf_boxes(&st.doc), &s.cache, &opts) + .map_err(|e| EngineError::Other(e.to_string()))?; + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), + } +} + +/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact. +fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec) { + // Match the exporter's reading order (including Article exclusions), not every asset + // in the document: unused or hidden graphics must not prevent an export. + let mut used = std::collections::BTreeSet::new(); + for block in designcraft_epub::reading_order(d) { + let designcraft_epub::Block::Image(id) = block else { + continue; + }; + let Some(it) = d.item(id) else { continue }; + let designcraft_doc::Content::Graphic(g) = &it.content else { + continue; + }; + used.insert(g.asset); + if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some()) + && let Some(poster) = it.media.as_ref().and_then(|m| m.poster) + { + used.insert(poster); + } + } + let mut out = Cow::Borrowed(d); + let mut warnings = Vec::new(); + for id in used { + let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { + continue; + }; + let png = designcraft_images::eps_png(&a.data); + if png.is_none() { + warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name)); + } + if let Some(a) = out.to_mut().assets.get_mut(&id) { + let a = Arc::make_mut(a); + a.mime = "image/png".into(); + a.data = Arc::new(png.unwrap_or_default()); + } + } + (out, warnings) +} + +pub(super) fn export_html(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = designcraft_epub::HtmlOptions { + title: str_param(p, "title").map(str::to_string), + language: str_param(p, "language").map(str::to_string), + generic_families: generic_families(&st.doc), + }; + let (doc, warnings) = eps_previews(&st.doc); + let text = designcraft_epub::export_html(&doc, &opts); + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings})) + } + None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})), + } +} + +pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportPptx"; + let st = s.doc()?; + let line_breaks = match str_param(p, "lineBreaks") { + None | Some("keep") => designcraft_pptx::LineBreaks::Keep, + Some("reflow") => designcraft_pptx::LineBreaks::Reflow, + Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), + }; + let opts = designcraft_pptx::PptxOptions { + pages: pages_param(ID, p, st.doc.page_count())?, + line_breaks, + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + created: None, + embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), + }; + // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame + // stays text: a picture of it would drop the rest of its story). + let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); + let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| { + !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) + }); + let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + if rasterized > 0 { + r.warnings.push(format!("{rasterized} object(s) went in as images")); + } + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), + } +} + +pub(super) fn has_story_target(s: &Session) -> std::result::Result<(), String> { + crate::cmd::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) +} + +pub(super) fn export_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportText"; + let sid = crate::cmd::text::story_of(s, p).ok_or_else(|| bad(ID, "edit text or select a text frame"))?; + let doc = &s.doc()?.doc; + let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?; + let path = str_param(p, "path"); + let format = match str_param(p, "format") { + Some(f @ ("rtf" | "tagged")) => f, + Some("txt") | Some("text") => "txt", + Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))), + None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf", + None => "txt", + }; + let text = match format { + "rtf" => designcraft_textimport::export::rtf(doc, story), + "tagged" => designcraft_textimport::tagged::tagged_text(doc, story), + _ => designcraft_textimport::export::plain_text(story), + }; + match path { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": text.len()})) + } + None => Ok(json!({"bytes": text.len(), "text": text})), + } +} + +/// Text of the frames on page `abs`, in line order (for fixed-layout pages). +fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String { + let mut out = String::new(); + for st in d.stories.values() { + let cs = cache.get(d, st.id, None); + for ft in &cs.frames { + if d.page_of_item(ft.frame) != Some(abs) { + continue; + } + for l in &ft.lines { + let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); + let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); + if !t.trim().is_empty() { + out.push_str(t.trim_end_matches('\n')); + out.push('\n'); + } + } + } + } + out +} + +/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' +/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. +fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { + use designcraft_fonts::FontClass; + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = std::collections::BTreeMap::new(); + for (family, _) in designcraft_epub::stylesheet_fonts(d) { + if out.contains_key(&family) { + continue; + } + let generic = match db.family_kind(&family).map(|(k, _)| k.class) { + Some(FontClass::Serif | FontClass::SlabSerif) => "serif", + Some(FontClass::SansSerif) => "sans-serif", + Some(FontClass::Monospaced) => "monospace", + Some(FontClass::Script) => "cursive", + Some(FontClass::Decorative) => "fantasy", + Some(FontClass::Symbol | FontClass::Other) | None => continue, + }; + out.insert(family, generic.to_string()); + } + out +} + +/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out +/// (not available, a licence that forbids embedding, or part of a font collection). +fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = Vec::new(); + for (family, style) in designcraft_epub::stylesheet_fonts(d) { + let face = db.face(&family, &style); + if !face.family.eq_ignore_ascii_case(&family) { + warnings.push(format!("font `{family} {style}` is not available; not embedded")); + } else if face.restricted_licence() || face.bitmap_only() { + warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); + } else if face.data().starts_with(b"ttcf") { + warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); + } else { + out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); + } + } + out +} + +/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph +/// style the document has. +pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result { + let split: designcraft_doc::EpubSplit = + serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?; + if let designcraft_doc::EpubSplit::Style(name) = &split + && doc.styles.para(name).is_none() + { + return Err(bad(id, format!("no paragraph style `{name}`"))); + } + Ok(split) +} + +pub(super) fn export_epub(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?; + // Object Export Options › Rasterize: those objects go in as images. + let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it)); + let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false); + let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let mut warnings: Vec = Vec::new(); + // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. + let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { + Some(path) => { + Some(crate::cmd::datamerge::read_path_capped(path, crate::cmd::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?) + } + None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), + }; + if let Some(c) = &cover + && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) + { + return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); + } + let opts = designcraft_epub::EpubOptions { + title: p.get("title").and_then(Value::as_str).map(str::to_string), + author: p.get("author").and_then(Value::as_str).map(str::to_string), + language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), + identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), + cover, + toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), + split, + generic_families: generic_families(&st.doc), + fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { + embedded_fonts(&st.doc, &mut warnings) + } else { + Vec::new() + }, + // The export time; on the web the document's own save time stands in. + #[cfg(not(target_arch = "wasm32"))] + modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), + #[cfg(target_arch = "wasm32")] + modified: None, + }; + if fixed { + let d = &st.doc; + let mut pages = Vec::new(); + for i in 0..d.page_count() { + let Some((si, pi)) = d.page_loc(i) else { + continue; + }; + let b = d.spreads[si].pages[pi].bounds(); + let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { + continue; + }; + pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) }); + } + let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + return match p.get("path").and_then(Value::as_str) { + Some(path) => { + write_file(path, &bytes)?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()})) + } + None => Ok(serde_json::json!({"base64": crate::cmd::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), + }; + } + let (epub_doc, eps_warnings) = eps_previews(epub_doc); + warnings.extend(eps_warnings); + let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + warnings.extend( + designcraft_epub::images_without_alt(&epub_doc) + .into_iter() + .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")), + ); + match p.get("path").and_then(Value::as_str) { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) + } + None => Ok(serde_json::json!({"base64": crate::cmd::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + crate::io::write_file(path, bytes).map_err(EngineError::Other) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes"))) +} + +#[cfg(test)] +mod booklet_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn booklet_imposes_pages_in_pairs() { + use designcraft_pdf::{BookletKind, booklet_pairs}; + assert_eq!(booklet_pairs(8, BookletKind::SaddleStitch), [(Some(7), Some(0)), (Some(1), Some(6)), (Some(5), Some(2)), (Some(3), Some(4))]); + assert_eq!(booklet_pairs(6, BookletKind::SaddleStitch)[0], (None, Some(0)), "padded with blanks"); + assert_eq!(booklet_pairs(3, BookletKind::TwoUpConsecutive), [(Some(0), Some(1)), (Some(2), None)]); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let r = s.execute("file.printBooklet", &json!({"spaceBetween": 18})).unwrap(); + assert_eq!(r["sheets"], 2); + let pdf = String::from_utf8_lossy(&crate::cmd::file::base64_decode(r["base64"].as_str().unwrap())).into_owned(); + let mb = pdf.find("/MediaBox").map(|i| pdf[i..i + 40].to_string()).unwrap_or_default(); + assert!(mb.contains("1242"), "two 612 pt pages and an 18 pt gap: {mb}"); + assert!(s.execute("file.printBooklet", &json!({"type": "perfectBound"})).is_err()); + // Page 1's black box lands on the right half of the first sheet (page 4 is on the left). + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); + let r = s.execute("file.printBooklet", &json!({})).unwrap(); + let bytes = crate::cmd::file::base64_decode(r["base64"].as_str().unwrap()); + let px = designcraft_render::decode_pixmap_page(&bytes, 0).expect("rasterised"); + let (w, h) = (px.width() as f64, px.height() as f64); + let at = |x: f64, y: f64| px.sample((x / 1224.0 * w) as u16, (y / 792.0 * h) as u16); + let (ink, blank) = (at(612.0 + 200.0, 200.0), at(200.0, 200.0)); + assert!(ink.a > 200 && ink.r < 60, "right half has page 1: {ink:?}"); + assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}"); + } +} + +#[cfg(test)] +mod pptx_tests { + use serde_json::json; + + use crate::Session; + + fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(crate::cmd::file::base64_decode(b64))).unwrap() + } + + fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s + } + + #[test] + fn sample_exports_editable_slides() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + assert_eq!(r["slides"], 4); + let mut z = unzip(r["base64"].as_str().unwrap()); + // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. + assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); + assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); + let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); + assert!(text.contains("Notes on the Grid"), "{text}"); + assert!(read(&mut z, "ppt/presentation.xml").contains("")); + + let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); + assert_eq!(r["slides"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); + assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); + assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); + } + + #[test] + fn rasterize_option_places_a_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); + let shapes = |s: &mut Session| { + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") + }; + assert!(shapes(&mut s).contains("prst=\"ellipse\"")); + s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); + let x = shapes(&mut s); + assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); + } +} diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs new file mode 100644 index 0000000000..64011fa3cd --- /dev/null +++ b/crates/engine/src/cmd/export/pdf.rs @@ -0,0 +1,463 @@ +use super::flatten::{flatten, rasterize_effects, rasterize_layered, shadow_masks, transparent}; +use super::formats::write_file; +use super::*; + +/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. +pub(super) fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { + Ok(match p.get("pages") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), + Some(Value::Array(a)) => { + let mut v = Vec::new(); + for x in a { + let n = x + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {x}")))?; + // A page named twice is one page, as `"2,2"` already is: the list says which + // pages to export, not how many times. `[2, 2]` exported two copies of page 2 + // while `"2,2"` exported one. + if !v.contains(&(n - 1)) { + v.push(n - 1); + } + } + Some(v) + } + Some(Value::Number(n)) => { + let n = n + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {n}")))?; + Some(vec![n - 1]) + } + Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), + }) +} + +pub(crate) fn options(p: &Value, page_count: usize) -> Result { + const ID: &str = "file.exportPdf"; + + let pages = pages_param(ID, p, page_count)?; + let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); + let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); + let marks = match p.get("marks") { + Some(Value::Bool(true)) => Marks::ALL, + Some(Value::Object(m)) => { + let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); + Marks { + crop: b("crop"), + bleed: b("bleed"), + page_info: b("pageInfo"), + weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25), + offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0), + } + } + _ => Marks::NONE, + }; + let standard = match str_param(p, "standard") { + Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, x1a, a2b)")))?, + None => Standard::None, + }; + let created = match p.get("created") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?), + }; + Ok(PdfOptions { + pages, + spreads, + bleed, + marks, + standard, + compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), + media: p.get("media").and_then(Value::as_bool).unwrap_or(false), + created, + ..PdfOptions::default() + }) +} + +/// Object number of the first page of a PDF (its page tree's first kid). +fn first_page_ref(pdf: &[u8]) -> Option { + let s = String::from_utf8_lossy(pdf); + let i = s.find("/Kids[")? + 6; + s[i..].split_whitespace().next()?.parse().ok() +} + +pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result { + let r = pdf_export(s, p)?; + match str_param(p, "path") { + Some(path) => { + write_file(path, &r.bytes)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), + } +} + +/// Transparency Flattener presets (`flatten`): High / Medium / Low Resolution, or a ppi. +pub(crate) fn flatten_ppi(id: &str, p: &Value) -> Result> { + Ok(match p.get("flatten") { + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::String(s)) if s == "high" => Some(300.0), + Some(Value::String(s)) if s == "medium" => Some(150.0), + Some(Value::String(s)) if s == "low" => Some(72.0), + Some(Value::Bool(true)) => Some(300.0), + Some(v) => Some(v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad(id, "`flatten`: high|medium|low or 36–1200 ppi"))?), + }) +} + +/// The print PDF of `opts`' pages: the transparency flattener at `ppi` (if any), placed PDFs +/// with hidden layers and soft effects as images, the PDF boxes of placed graphics, the PDF +/// itself and its transparency blend space. Export PDF and File › Print add their view options +/// to it ([`pdf_export`]); Separations Preview separates it as it is. +pub(crate) fn print_pdf( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + opts: &PdfOptions, + ppi: Option, +) -> Result { + // PDF/X-1a: craft-prepress flattens the PDF in its inks; `ppi` sets its resolution. + let x1a = opts.standard == Standard::PdfX1a; + let mut opts = opts.clone(); + if x1a { + opts.flatten_dpi = ppi.unwrap_or(300.0) as f32; + } + let flattened = if x1a { None } else { ppi.map(|ppi| flatten(d, cache, ppi)) }; + let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0); + let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; + let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); + let shadows = if x1a { shadow_masks(base2, cache, f64::from(opts.flatten_dpi)) } else { Default::default() }; + let (fx_doc, fx_count) = rasterize_effects(base2, cache, &shadows); + opts.shadows = std::sync::Arc::new(shadows); + let opts = &opts; + let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); + let boxed = crate::cmd::interchange::with_pdf_boxes(doc); + let doc: &designcraft_doc::Document = &boxed; + let mut r = designcraft_pdf::export_pdf_with_report(doc, cache, opts).map_err(|e| EngineError::Other(e.to_string()))?; + // Flattening and effect rasterization replace placements, so inspect the source too. + if d.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { + r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); + } + // Transparency Blend Space: the page group of pages with transparency. CMYK print output + // gets its CMYK page groups from the writer. + let spreads = designcraft_pdf::sheet_spreads(doc, opts); + let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); + if !designcraft_pdf::print_color(doc, opts) && blended.iter().any(|b| *b) { + match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { + Some(b) => r.bytes = b, + None => r.warnings.push("the transparency blend space couldn't be set".into()), + } + } + if fx_count > 0 { + r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) exported as images")); + } + if layer_count > 0 { + r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); + } + if let Some((_, n)) = &flattened + && *n > 0 + { + r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); + } + Ok(r) +} + +/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF +/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. +pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = options(p, st.doc.page_count())?; + let ppi = flatten_ppi("file.exportPdf", p)?; + let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; + let doc: &designcraft_doc::Document = &st.doc; + let spreads = designcraft_pdf::sheet_spreads(doc, &opts); + // Interactive PDF view options. + let mut cat = String::new(); + if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/FullScreen"); + } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/UseOutlines"); + } + match str_param(p, "pageLayout") { + Some("single") => cat.push_str("/PageLayout/SinglePage"), + Some("continuous") => cat.push_str("/PageLayout/OneColumn"), + Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), + Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), + Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), + None => {} + } + if let Some(v) = str_param(p, "view") { + // The first page opens with this view (pages are objects in order, so refer by index 0). + let dest = match v { + "fitPage" => "/Fit", + "fitWidth" => "/FitH null", + "actual" => "/XYZ null null 1", + o => { + return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))); + } + }; + if let Some(first) = first_page_ref(&r.bytes) { + cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); + } + } + if !cat.is_empty() { + match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) { + Some(b) => r.bytes = b, + None => r.warnings.push("the PDF view options couldn't be set".into()), + } + } + // Flip pages every N seconds (presentation). + if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) { + let n = designcraft_pdf::sheet_spreads(doc, &opts).len(); + let dur: Vec> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); + match designcraft_pdf::add_page_entries(&r.bytes, &dur) { + Some(b) => r.bytes = b, + None => r.warnings.push("page timings couldn't be set".into()), + } + } + // Page transitions (each spread's first page holds them). + let trans: Vec> = + spreads.into_iter().map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)).collect(); + if trans.iter().any(Option::is_some) { + match designcraft_pdf::add_transitions(&r.bytes, &trans) { + Some(b) => r.bytes = b, + None => r.warnings.push("page transitions couldn't be added to this PDF".into()), + } + } + Ok(r) +} + +#[cfg(test)] +mod tagged_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tagged_pdf_has_structure_and_alt_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 320, 200, 420]})).unwrap(); + s.execute("object.altText", &json!({"text": "A plain box", "ids": [r["id"]]})).unwrap(); + let pdf = |s: &mut Session, tagged: bool| { + let b64 = s.execute("file.exportPdf", &json!({"tagged": tagged})).unwrap()["base64"].as_str().unwrap().to_string(); + String::from_utf8_lossy(&crate::cmd::file::base64_decode(&b64)).into_owned() + }; + let t = pdf(&mut s, true); + assert!(t.contains("/StructTreeRoot") && t.contains("/Figure") && t.contains("A plain box"), "tagged"); + assert!(t.contains("/P") && t.contains("/MarkInfo")); + assert!(!pdf(&mut s, false).contains("/StructTreeRoot")); + } +} + +#[cfg(test)] +mod variable_font_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn variable_font_instance_exports_to_pdf() { + let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else { + return; + }; + designcraft_fonts::FontDb::global().add_font(data); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Variable"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Skia", "fontStyle": "Black"})).unwrap(); + let out = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(out["bytes"].as_u64().unwrap() > 1000); + let fonts = s.execute("font.list", &json!({})).unwrap(); + assert!(fonts.to_string().contains("Black"), "{fonts}"); + } +} + +#[cfg(test)] +mod placed_pdf_print_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::{base64_decode, base64_encode}; + + /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. + fn logo() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + base64_decode(r["base64"].as_str().unwrap()) + } + + fn page_with(pdf: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); + s + } + + /// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with + /// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)". + #[test] + fn pdf_17_placed_in_pdfx4() { + let logo = logo(); + assert!(logo.starts_with(b"%PDF-1.7")); + let mut s = page_with(&logo); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap(); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6")); + // PDF 2.0 can't simply be relabelled: the export fails and names the file. + let mut v20 = logo.clone(); + v20[5..8].copy_from_slice(b"2.0"); + let mut s = page_with(&v20); + let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); + assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); + } + + /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. + fn catalog_v17(header: &str) -> Vec { + let content = "0 0 0 1 k 0 0 200 200 re f"; + let mut pdf = format!("{header}\n").into_bytes(); + let mut offsets = Vec::new(); + for body in [ + "<>".to_owned(), + "<>".to_owned(), + "<>>>".to_owned(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + pdf + } + + /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export + /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). + #[test] + fn catalog_version_17_placed_in_pdfx4() { + for header in ["%PDF-1.7", "%PDF-1.4"] { + let pdf = catalog_v17(header); + assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); + let mut s = page_with(&pdf); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + let out = base64_decode(r["base64"].as_str().unwrap()); + assert!(out.starts_with(b"%PDF-1.6")); + assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); + } + } +} + +#[cfg(test)] +mod print_group_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::{base64_decode, base64_encode}; + + /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. + fn logo() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + base64_decode(r["base64"].as_str().unwrap()) + } + + fn page_with(pdf: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); + s + } + + /// A placed PDF's transparency group blends in CMYK in a print document and in RGB in a + /// screen one. + #[test] + fn print_transparency_groups_blend_in_cmyk() { + let mut s = page_with(&logo()); + let text = |s: &mut Session| { + let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap(); + String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned() + }; + let t = text(&mut s); + // The placed page's group inherits the page group, which blends in CMYK. + assert!(t.contains("/Group<>"), "placed page group without a blending space"); + assert!(t.contains("/Group<>"), "CMYK page group"); + assert!(!t.contains("DeviceRGB"), "no RGB left"); + assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes())); + // The file still reads, with the same page. + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = base64_decode(r["base64"].as_str().unwrap()); + assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1); + // An RGB blend space keeps RGB groups. + s.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); + let t = text(&mut s); + assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); + } +} + +#[cfg(test)] +mod page_list_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn doc(pages: u64) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap(); + s + } + + /// The same selection written two ways exported different files: `"2,2"` is one page, because + /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2. + /// The list says which pages to export, not how many times. + #[test] + fn a_page_named_twice_is_exported_once_either_way() { + let mut s = doc(4); + for (pages, want) in [ + (json!("2,2"), 1), + (json!([2, 2]), 1), + (json!([2, 2, 2]), 1), + (json!("2-2,2"), 1), + (json!([1, 2, 1]), 2), + (json!("1,2,1"), 2), + (json!([4, 1]), 2), + (json!("4,1"), 2), + (json!("all"), 4), + ] { + let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}"); + let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}"); + } + // The two forms now agree byte for byte, and the pages come out in the document's order + // whichever order the list names them in (which is how the export already behaved). + // A fixed creation time: exports a second apart would differ in their dates. + let mut file = |pages: Value| { + let r = s.execute("file.exportPdf", &json!({"pages": pages, "created": 0})).unwrap(); + r["base64"].as_str().unwrap_or_default().to_string() + }; + assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree"); + assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order"); + assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page"); + // A page that does not exist is still refused, and an empty list still exports nothing. + assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err()); + assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); + } +} diff --git a/crates/engine/src/cmd/export/registry.rs b/crates/engine/src/cmd/export/registry.rs new file mode 100644 index 0000000000..dfa126b890 --- /dev/null +++ b/crates/engine/src/cmd/export/registry.rs @@ -0,0 +1,53 @@ +use super::formats::{epub_split_param, export_epub, export_html, export_pptx, export_text, has_story_target, print_booklet}; +use super::pdf::export_pdf; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised; with standard x1a, the resolution complex transparent regions are rasterized at: high 300, medium 150, low 72, default 300), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"x1a\"|\"a2b\" (x1a: PDF/X-1a:2003, the PDF/X-4 file flattened in its inks by craft-prepress: no transparency, only CMYK, gray and spot color), compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})", + has_doc, export_pdf), + cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, + "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", + has_doc, export_epub), + cmd!( + "document.epubSplit", + "EPUB Split Document", + [], + None, + "{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}", + has_doc, + |s, p| { + const ID: &str = "document.epubSplit"; + let cur = s.doc()?.doc.settings.epub_split.clone(); + let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) }; + let split = epub_split_param(ID, &s.doc()?.doc, v)?; + if split != cur { + let stored = split.clone(); + s.edit(move |d, _| { + d.settings.epub_split = stored; + Ok(Value::Null) + })?; + } + Ok(json!({"split": split})) + } + ), + cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, + "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", + has_doc, |s, p| { + export_epub(s, &crate::cmd::with_param(p, "fixedLayout", serde_json::json!(true))) + }), + cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None, + "{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})", + has_doc, print_booklet), + cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, + "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes, warnings} (no path: {text, bytes, warnings})", + has_doc, export_html), + cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", + has_doc, export_pptx), + cmd!(noundo "file.exportText", "Export Text…", ["File"], None, + "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", + has_story_target, export_text), + ] +} diff --git a/crates/engine/src/cmd/export/text_tests.rs b/crates/engine/src/cmd/export/text_tests.rs new file mode 100644 index 0000000000..e533320f84 --- /dev/null +++ b/crates/engine/src/cmd/export/text_tests.rs @@ -0,0 +1,378 @@ +use serde_json::json; + +use crate::Session; + +fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() +} + +fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s +} + +/// Split Document is stored with the document as one undo step, follows a renamed style, and +/// an export can override it without storing its own. +#[test] +fn epub_split_document_setting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nText\nTwo\nMore"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); + for at in [0, 9] { + s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap(); + } + let files = |s: &mut Session, p: serde_json::Value| { + let mut z = unzip(s.execute("file.exportEpub", &p).unwrap()["base64"].as_str().unwrap()); + (1..).take_while(|n| z.by_name(&if *n == 1 { "OEBPS/content.xhtml".into() } else { format!("OEBPS/content{n}.xhtml") }).is_ok()).count() + }; + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); + assert_eq!(files(&mut s, json!({})), 1, "one content file by default"); + assert!(s.execute("document.epubSplit", &json!({"split": {"style": "Nope"}})).is_err()); + assert!(s.execute("document.epubSplit", &json!({"split": "h1"})).is_err()); + assert!(s.execute("file.exportEpub", &json!({"split": {"style": "Nope"}})).is_err()); + assert_eq!(s.execute("document.epubSplit", &json!({"split": {"style": "Chapter"}})).unwrap()["split"], json!({"style": "Chapter"})); + assert_eq!(files(&mut s, json!({})), 2); + assert_eq!(files(&mut s, json!({"split": "off"})), 1); + assert_eq!(s.doc().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Style("Chapter".into()), "the override isn't stored"); + s.execute("style.paragraph.edit", &json!({"name": "Chapter", "rename": "Part"})).unwrap(); + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], json!({"style": "Part"})); + assert_eq!(files(&mut s, json!({})), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); +} + +#[test] +fn object_export_options() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // A plain rectangle rasterised for EPUB, centred, after a page break. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); + s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true, "align": "center", "pageBreakBefore": true, "altText": "A dot"})) + .unwrap(); + let e = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = unzip(e["base64"].as_str().unwrap()); + let x = read(&mut z, "OEBPS/content.xhtml"); + assert!(x.contains("
")); + assert!(z.by_name("OEBPS/images/cover.png").is_ok()); + let r = s.execute("file.exportFixedEpub", &json!({})).unwrap(); + assert_eq!(r["pages"], 2); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("rendition:layout\">pre-paginated") && opf.contains("page2.xhtml")); + let p1 = read(&mut z, "OEBPS/page1.xhtml"); + assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); +} + +/// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts +/// (and reports those it can't). +#[test] +fn epub_font_fallbacks_and_embedding() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}"); + assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked"); + let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(css.contains("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}"); + let opf = read(&mut z, "OEBPS/content.opf"); + let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}")); + assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}"); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("\"Source Sans 3\", sans-serif;")); +} + +/// A font whose licence allows embedding bitmaps only (fsType bit 9) is not packaged whole, +/// and a Semibold style asks for the Semibold face's weight, not Bold's. +#[test] +fn epub_embedding_follows_bitmap_only_licences_and_weights() { + use designcraft_fonts::testing::{font_with, with_fs_type}; + const BITMAP: &str = "DocFont Epub Bitmap Only"; + for fs_type in [0x0200, 0x0208] { + designcraft_fonts::FontDb::global().add_font(with_fs_type(font_with(&format!("{BITMAP} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap()); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + for fs_type in [0x0200, 0x0208] { + let family = format!("{BITMAP} {fs_type:04x}"); + s.execute("style.paragraph.create", &json!({"name": family, "chars": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap(); + } + s.execute("style.paragraph.create", &json!({"name": "Semi", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Semibold"}})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(!css.contains(&format!("@font-face {{ font-family: \"{BITMAP}")), "{css}"); + let warnings = r["warnings"].to_string(); + for fs_type in [0x0200, 0x0208] { + assert!(warnings.contains(&format!("{BITMAP} {fs_type:04x} Regular`: its licence doesn't allow embedding")), "{warnings}"); + } + let semi = css.lines().find(|l| l.contains(".semi {")).unwrap_or_default(); + assert!(semi.contains("font-weight: 600;"), "{css}"); +} + +/// #771, #773: a cover image file and an identifier through the command; the package's +/// modified time is the export's. +#[test] +fn epub_cover_image_file_and_identifier() { + let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let jpg = dir.join("cover.jpg"); + std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap(); + let bad = dir.join("cover.txt"); + std::fs::write(&bad, b"not an image").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("urn:isbn:9780000000002"), "{opf}"); + assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}"); + assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}"); + assert!(z.by_name("OEBPS/images/cover.jpg").is_ok()); + assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err()); + std::fs::remove_dir_all(&dir).ok(); +} + +/// The color spaces of every image XObject in `pdf`, as the families and colorant names. +fn image_spaces(pdf: &[u8]) -> Vec { + use hayro_syntax::object::{Name, Object}; + let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap(); + file.objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image")) + .filter_map(|s| match s.dict().get::>(b"ColorSpace")? { + Object::Name(n) => Some(n.as_str().to_string()), + Object::Array(a) => { + let mut it = a.iter::>(); + let family = it.next()?.into_name()?.as_str().to_string(); + let names = match it.next() { + Some(Object::Array(n)) => n.iter::>().map(|n| n.as_str().to_string()).collect::>().join(" "), + Some(Object::Name(n)) => n.as_str().to_string(), + _ => String::new(), + }; + Some(format!("{family} {names}")) + } + _ => None, + }) + .collect() +} + +/// The (Black, Gold) samples of every flattened DeviceN image that has both colorants. +fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> { + use hayro_syntax::object::{Array, Name, Object}; + let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap(); + let mut out = Vec::new(); + for st in file.objects().into_iter().filter_map(|o| o.into_stream()) { + let d = st.dict(); + if !d.get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { + continue; + } + let Some(cs) = d.get::>(b"ColorSpace") else { continue }; + let mut it = cs.iter::>(); + if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") { + continue; + } + let Some(Object::Array(names)) = it.next() else { continue }; + let names: Vec = names.iter::>().map(|n| n.as_str().to_string()).collect(); + let (Some(k), Some(g)) = (names.iter().position(|n| n == "Black"), names.iter().position(|n| n == "Gold")) else { continue }; + let data = st.decoded().unwrap(); + out.extend(data.chunks(names.len()).filter(|px| px.len() == names.len()).map(|px| (px[k], px[g]))); + } + out +} + +/// The shadow coverage gives the rendered pixel back when the PDF draws the object over the +/// shadow, also under a partly transparent object (an anti-aliased edge, a soft-alpha image). +#[test] +fn shadow_coverage_composites_back() { + for a in (0..=255u32).step_by(15) { + for s in (0..=255u32).step_by(15) { + let over = |c: u32| a + (c * (255 - a) + 127) / 255; + let with = over(s) as u8; + let c = u32::from(super::shadow_coverage(with, a as u8)); + assert!(over(c).abs_diff(u32::from(with)) <= 1, "object {a}, shadow {s}: coverage {c}"); + } + } + assert_eq!(super::shadow_coverage(255, 255), 0); + assert_eq!(super::shadow_coverage(10, 20), 0); +} + +/// PDF/X-1a through craft-prepress: a soft drop shadow over a spot, a 50% object and a +/// knockout group leave no transparency and no RGB; the shadow is flattened in the page's +/// inks (Black over the spot), not exported as an RGB image of the object; text stays text and +/// overprint stays `/op`; `flatten` sets the resolution. +#[test] +fn x1a_export_flattens_in_inks() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap(); + let bg = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [bg], "swatch": "Gold"})).unwrap(); + let shadowed = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [shadowed], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.dropShadow", &json!({"ids": [shadowed], "on": true, "distance": 8, "size": 6})).unwrap(); + let half = s.execute("frame.create", &json!({"rect": [300, 72, 400, 172]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [half], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + s.execute("object.opacity", &json!({"ids": [half], "opacity": 0.5})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 300, 172, 400]})).unwrap()["id"].clone(); + let b = s.execute("frame.create", &json!({"rect": [122, 350, 222, 450]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [a, b], "swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); + s.execute("object.opacity", &json!({"ids": [a, b], "opacity": 0.5})).unwrap(); + let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].clone(); + s.execute("object.transparencyGroup", &json!({"ids": [g], "knockout": true})).unwrap(); + let op = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [op], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.attributes", &json!({"ids": [op], "overprintFill": true})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 500, 400, 560], "content": "text", "text": "Live text", "caret": false})).unwrap(); + + let r = s.execute("file.exportPdf", &json!({"standard": "x1a", "flatten": "low"})).unwrap(); + let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + let text = String::from_utf8_lossy(&pdf).to_string(); + assert_eq!(designcraft_pdf::check_pdfx1a(&pdf), Vec::::new()); + assert!(pdf.starts_with(b"%PDF-1.4") && text.contains("/GTS_PDFXVersion(PDF/X-1:2003)") && text.contains("/S/GTS_PDFX")); + assert!(text.contains("/GTS_PDFXConformance(PDF/X-1a:2003)"), "the conformance level"); + let warnings = r["warnings"].to_string(); + assert!(!warnings.contains("soft effects"), "the shadow isn't an RGB image of the object: {warnings}"); + assert!(warnings.contains("at 72 ppi"), "{warnings}"); + let spaces = image_spaces(&pdf); + assert!(spaces.iter().all(|cs| !cs.contains("RGB") && !cs.contains("ICC")), "{spaces:?}"); + assert!(spaces.iter().any(|cs| cs.starts_with("DeviceN") && cs.contains("Black") && cs.contains("Gold")), "the shadow over the spot: {spaces:?}"); + // The shadow multiplies: the spot stays at 100% where the shadow's black is. + let shadowed: Vec<(u8, u8)> = black_and_gold(&pdf).into_iter().filter(|(k, _)| *k > 100).collect(); + assert!(!shadowed.is_empty(), "no shadow pixels"); + assert!(shadowed.iter().all(|(_, gold)| *gold >= 250), "the spot under the shadow: {:?}", &shadowed[..shadowed.len().min(8)]); + assert!(text.contains("/op true") && text.contains("/OPM 1"), "overprint"); + assert!(text.contains("/FontFile"), "the text is still text"); + // The default resolution. + let r = s.execute("file.exportPdf", &json!({"standard": "x1a"})).unwrap(); + assert!(r["warnings"].to_string().contains("at 300 ppi"), "{}", r["warnings"]); +} + +#[test] +fn flattener_rasterises_spreads_with_transparency() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); + // Opaque: nothing to flatten. + let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("flattened"))); + s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); + // Text away from the transparency stays live text. + s.execute("frame.create", &json!({"rect": [72, 400, 400, 450], "content": "text", "text": "Still vector", "caret": false})).unwrap(); + let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); + let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + assert!(String::from_utf8_lossy(&pdf).contains("/FontFile"), "the text is still text"); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened: 1 ")), "{r}"); + assert_eq!(r["pages"], 3); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(3)); + // The document itself is untouched. + assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).is_some()); + assert!(s.execute("file.exportPdf", &json!({"flatten": 5})).is_err()); + // Interactive view options land in the catalog and on the pages. + let r = s.execute("file.exportPdf", &json!({"fullScreen": true, "pageLayout": "single", "view": "fitPage", "advanceSeconds": 5})).unwrap(); + let t = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); + assert!( + t.contains("/PageMode/FullScreen") && t.contains("/PageLayout/SinglePage") && t.contains("/OpenAction[") && t.contains("/Dur 5.00"), + "view options" + ); + assert!(s.execute("file.exportPdf", &json!({"pageLayout": "sideways"})).is_err()); + // Unflattened, the page blends in the document's blend space (CMYK for print). + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); + assert_eq!(text.matches("/S/Transparency/CS/DeviceCMYK").count(), 1, "only the page with transparency"); +} + +#[test] +fn objects_with_soft_effects_keep_their_text_wrap_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Words wrap around the objects on this page. ".repeat(60); + let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": text, "caret": false})).unwrap()["id"] + .as_u64() + .unwrap(); + // A feathered frame with a contour wrap of the frame itself. + let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [a], "mode": "contour", "contour": "sameAsClipping", "offset": 6})).unwrap(); + // A feathered frame whose placed graphic has a bounding-box wrap. + let png = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![90; 40 * 40 * 4] }.to_png(); + let b = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": b})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [b], "content": true, "mode": "boundingBox", "offset": 6})).unwrap(); + s.execute("object.feather", &json!({"ids": [a, b], "width": 14})).unwrap(); + + let d = &s.doc().unwrap().doc; + let sid = d.item(designcraft_doc::ItemId(t)).and_then(|it| it.text_frame()).unwrap().story; + let before = designcraft_compose::frame_specs(d, sid)[0].exclusions.clone(); + assert_eq!(before.len(), 2); + let (fx, n) = super::rasterize_effects(d, &s.cache, &Default::default()); + assert_eq!(n, 2); + let fx = fx.unwrap(); + assert_eq!(designcraft_compose::frame_specs(&fx, sid)[0].exclusions, before); + // The objects that keep the wrap don't print; their images do. + let sp = &fx.spreads[0]; + assert_eq!(sp.items.iter().filter(|it| it.nonprinting).count(), 2); + assert_eq!(sp.items.iter().filter(|it| !it.nonprinting && it.graphic().is_some()).count(), 2); +} + +#[test] +fn export_story_as_text_and_rtf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nTwo"})).unwrap(); + assert!(s.execute("file.exportText", &json!({"frame": r["id"]})).unwrap()["text"] == "One\r\nTwo"); + let html = s.execute("file.exportHtml", &json!({})).unwrap(); + assert!(html["text"].as_str().unwrap().contains("One

")); + let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap(); + assert!(tagged["text"].as_str().unwrap().contains(" Vec { - vec![ - cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"), - "{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?, primaryTextFrame?: bool}", - always, file_new), - cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), - cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| { - let mut presets: Vec = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect(); - presets.extend( - s.prefs - .document_presets - .iter() - .map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})), - ); - Ok(Value::Array(presets)) - }), - cmd!(noundo "file.savePreset", "Save Document Preset", [], None, - "{name, …the settings as file.new} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused", - always, save_preset), - cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| { - let name = str_param(p, "name").unwrap_or_default().trim().to_string(); - let before = s.prefs.document_presets.len(); - s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); - if s.prefs.document_presets.len() == before { - return Err(bad("file.deletePreset", format!("no saved preset `{name}`"))); - } - ok() - }), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", - always, file_open), - cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), - cmd!(noundo "file.openRecent", "Open Recent File", [], None, - "{index} — reopen entry `index` of File › Open Recent (as file.open); a file that is gone is an error and stays listed", - has_recent, file_open_recent), - cmd!(query "file.recentFiles", "Recent Files", [], None, "{} → [{path, name, exists}] File › Open Recent, most recent first", always, |s, _| { - Ok(Value::Array(s.prefs.recent_files.iter().map(|p| recent_entry(p)).collect())) - }), - cmd!(noundo "file.clearRecent", "Clear Recent Files", ["File", "Open Recent"], None, "{} — empty File › Open Recent", has_recent, |s, _| { - s.prefs.recent_files.clear(); - ok() - }), - cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), - cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), - cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| { - let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?; - let path = with_document_extension(path); - let mut copy = (*s.doc()?.doc).clone(); - super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); - let bytes = to_bytes_with_preview(©, &s.cache)?; - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": bytes.len()})) - }), - cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), - cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { - let dir = recovery_dir(s, p, FolderAccess::Read)?; - Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) - }), - cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { - let dir = recovery_dir(s, p, FolderAccess::Write)?; - Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) - }), - cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { - // Reopening removes the entries it read. - let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; - Ok(json!({"opened": crate::recovery::open(s, &dir)?})) - }), - cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { - let st = s.doc()?; - // Preview fill is session state. Serialize the unfilled template. - let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref()); - let bytes = to_bytes(doc)?; - Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})) - }), - cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| { - let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0); - s.close_document(i); - ok() - }), - cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| { - s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); - ok() - }), - cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, - "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", super::has_selection, snippet_export), - cmd!(query "place.styles", "Import Options: Styles", [], None, - "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", - has_doc, |s, p| { - let (bytes, name, _) = read_source(p)?; - let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; - let st = &s.doc()?.doc.styles; - let conflicts: Vec<&str> = imp - .para_styles - .iter() - .filter(|x| st.para(&x.name).is_some()) - .chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some())) - .map(|x| x.name.as_str()) - .collect(); - Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::>(), "conflicts": conflicts})) - }), - cmd!( - "snippet.place", - "Place Snippet", - [], - None, - "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", - has_doc, - snippet_place - ), - cmd!( - "place.load", - "Load Place Cursor", - [], - None, - "{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)", - has_doc, - place_load - ), - cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop), - cmd!( - "file.place", - "Place…", - ["File"], - Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", - has_doc, - file_place - ), - ] -} +mod bytes; +mod new; +mod open; +mod place; +mod registry; +mod save; +mod snippets; + +pub use bytes::{DOCUMENT_SNIFF_BYTES, base64_decode, base64_encode, from_bytes, is_document, to_bytes, with_document_extension}; +pub(crate) use open::load_document_fonts; +pub use open::{RecentPreview, recent_preview}; +pub(crate) use place::{crop_pdf, read_source, validate_placement}; +pub use registry::specs; +pub(crate) use snippets::{snippet_bytes, snippet_place}; #[cfg(test)] thread_local! { @@ -148,2027 +31,5 @@ thread_local! { pub(crate) static FAIL_TO_BYTES: std::cell::Cell = const { std::cell::Cell::new(false) }; } -/// The document as `.designcraft` bytes, or an error when it can't be written; never an empty file. -pub fn to_bytes(d: &Document) -> Result> { - save_bytes(d, None) -} - -/// [`to_bytes`] with `preview` (PNG bytes) stored as the file's first-page picture. -fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { - #[cfg(test)] - if FAIL_TO_BYTES.with(std::cell::Cell::get) { - return Err(EngineError::Other("could not write the document: injected failure".into())); - } - designcraft_format::save_with_preview(d, preview).map_err(|e| EngineError::Other(format!("could not write the document: {e}"))) -} - -/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so -/// the file opens from the file manager. A path without a file name is left for the write to fail. -pub fn with_document_extension(path: &str) -> String { - let p = std::path::Path::new(path); - let ext = p.extension().and_then(std::ffi::OsStr::to_str); - if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { - return path.to_string(); - } - format!("{path}.{}", designcraft_format::EXTENSION) -} - -/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. -/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their -/// `mimetype` entry, as both formats write them. -pub fn is_document(bytes: &[u8]) -> bool { - designcraft_idml::is_idml(bytes) || designcraft_format::starts_designcraft(bytes) || designcraft_format::is_designcraft(bytes) -} - -/// How much of a file [`is_document`] needs to recognise a package by its `mimetype` entry. -pub const DOCUMENT_SNIFF_BYTES: u64 = 64 << 10; - -/// The longer side of the first-page picture saved in the file, in pixels. -const PREVIEW_PX: f64 = 320.0; - -/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file -/// in its recent files. A page that can't be drawn just leaves the picture out. -fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Result> { - let preview = d.page_loc(0).and_then(|(si, pi)| { - let b = d.spreads.get(si)?.pages.get(pi)?.bounds(); - let side = b.width().max(b.height()); - (side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side) - }); - let png = - preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png()); - save_bytes(d, png.as_deref()) -} - -pub fn from_bytes(b: &[u8]) -> Result { - load_bytes(b).map(|(d, _)| d) -} - -/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the -/// open, with a warning for each repair. -fn load_bytes(b: &[u8]) -> Result<(Document, Vec)> { - let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?; - let warnings = d.fix_parent_bases(); - Ok((d, warnings)) -} - -/// The longest saved preset name, and how many presets are kept. -const MAX_PRESET_NAME: usize = 100; -const MAX_PRESETS: usize = 500; - -/// A built-in document preset or a saved one, by name (case ignored). -fn find_preset(s: &Session, name: &str) -> Option { - NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned()) -} - -/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. -fn new_document_settings(s: &Session, p: &Value) -> Result { - let mut nd = match str_param(p, "preset") { - Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), - }; - apply_settings(&mut nd, p); - check_size("file.new", &nd)?; - Ok(nd) -} - -/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a -/// paragraph's last word or across a column. Opened and imported files keep their own values, -/// which is why `ParaProps` keeps the IDML defaults (on). -fn new_document(nd: &NewDocument) -> Document { - let mut d = Document::new(nd); - if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { - st.para.hyph_capitalized = Some(false); - st.para.hyph_last_word = Some(false); - st.para.hyph_across_column = Some(false); - } - d -} - -fn file_new(s: &mut Session, p: &Value) -> Result { - let mut nd = new_document_settings(s, p)?; - s.untitled += 1; - nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = new_document(&nd); - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) -} - -impl Session { - /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while - /// the New Document window is open. It replaces the previous preview document in its tab and - /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses - /// leave the previous preview as it was. Returns the preview document's uid. - pub fn preview_new_document(&mut self, p: &Value) -> Result { - let mut nd = new_document_settings(self, p)?; - let previous = match self.new_document_preview { - Some((_, previous)) => previous, - None => self.active().map(|d| d.uid), - }; - let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); - // A preview edited or saved by a menu or script while the window is open has become a - // document of its own: it stays, and the preview gets a tab of its own. - let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none())); - if replace.is_none() && shown.is_some() { - self.untitled += 1; - } - nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(new_document(&nd), None); - let uid = st.uid; - match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { - Some((i, d)) => { - *d = st; - self.active = Some(i); - } - None => { - // Pushed without `add_document`: the document that was active stays as it is. - self.docs.push(st); - self.active = Some(self.docs.len() - 1); - } - } - self.new_document_preview = Some((uid, previous)); - Ok(uid) - } - - /// The uid of New Document's preview document, while one is shown. - pub fn new_document_preview(&self) -> Option { - self.new_document_preview.map(|(uid, _)| uid) - } - - /// End New Document's preview. Its document goes, and if it was still the active document, - /// the one that was active before it is active again. A preview that a menu or script has - /// edited or saved stays open as a document, with an Untitled number. Returns the uid of the - /// document removed. Without a preview it does nothing. - pub fn end_new_document_preview(&mut self) -> Option { - let (uid, previous) = self.new_document_preview.take()?; - let i = self.docs.iter().position(|d| d.uid == uid)?; - let d = self.docs.get(i)?; - if d.is_dirty() || d.path.is_some() { - self.untitled += 1; - return None; - } - self.docs.remove(i); - self.active = match self.active { - Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)), - Some(a) if a > i => Some(a - 1), - a => a, - }; - Some(uid) - } - - /// Create in the New Document window, with the settings the preview shows: the preview - /// document becomes the new document, as `file.new` would make it (the next Untitled number, - /// no undo history). Returns its index, or `None` when there is no unedited preview to keep. - pub fn keep_new_document_preview(&mut self) -> Option { - let (uid, _) = self.new_document_preview?; - let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?; - self.new_document_preview = None; - self.untitled += 1; - if let Some(d) = self.docs.get_mut(i) { - d.history.undo.clear(); - d.history.redo.clear(); - } - self.active = Some(i); - Some(i) - } -} - -fn save_preset(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string(); - if name.chars().count() > MAX_PRESET_NAME { - return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters"))); - } - if NewDocument::from_preset(&name).is_some() { - return Err(bad("file.savePreset", format!("`{name}` is a built-in preset"))); - } - let mut nd = NewDocument::default(); - apply_settings(&mut nd, p); - check_size("file.savePreset", &nd)?; - nd.title = name.clone(); - let presets = &mut s.prefs.document_presets; - presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); - if presets.len() >= MAX_PRESETS { - return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets"))); - } - presets.push(nd); - Ok(json!({"name": name})) -} - -fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> { - if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) { - return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)")); - } - Ok(()) -} - -/// The document settings in `p` (as file.new takes them) over `nd`. -fn apply_settings(nd: &mut NewDocument, p: &Value) { - if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) { - nd.intent = intent; - } - if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) { - nd.units = units; - } - nd.width = f64_or(p, "width", nd.width); - nd.height = f64_or(p, "height", nd.height); - nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); - nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page); - nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); - nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); - nd.gutter = f64_or(p, "gutter", nd.gutter); - nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame); - match p.get("margins") { - Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)), - Some(v @ Value::Object(_)) => { - if let Ok(m) = serde_json::from_value(v.clone()) { - nd.margins = m; - } - } - _ => {} - } - if let Some(b) = edges(p, "bleed") { - nd.bleed = b; - } - if let Some(b) = edges(p, "slug") { - nd.slug = b; - } -} - -/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside]. -fn edges(p: &Value, key: &str) -> Option<[f64; 4]> { - let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 }; - match p.get(key)? { - Value::Array(a) => match a.iter().map(Value::as_f64).collect::>>()?.as_slice() { - [t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]), - _ => None, - }, - v => v.as_f64().map(|b| [clean(b); 4]), - } -} - -fn file_sample(s: &mut Session, _p: &Value) -> Result { - let d = crate::sample::magazine(); - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) -} - -fn has_recent(s: &Session) -> std::result::Result<(), String> { - if s.prefs.recent_files.is_empty() { Err("no recent files".into()) } else { Ok(()) } -} - -/// A recent file's first-page picture as straight RGBA, for the start screen. -pub struct RecentPreview { - pub width: u32, - pub height: u32, - pub rgba: Vec, -} - -/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't -/// ours, or was saved before files had one (it gets one when saved again). -pub fn recent_preview(path: &str) -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - // Only the zip directory and the preview entry are read, not the whole document. - let png = designcraft_format::read_preview(std::io::BufReader::new(crate::io::open(path).ok()?))?; - let img = designcraft_images::decode_rgba(&png)?; - let (width, height) = img.dimensions(); - Some(RecentPreview { width, height, rgba: img.into_raw() }) - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - None - } -} - -/// One File › Open Recent row: the path, its file name and whether it is still there. -fn recent_entry(path: &str) -> Value { - let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); - #[cfg(not(target_arch = "wasm32"))] - let exists = crate::io::is_file(path); - #[cfg(target_arch = "wasm32")] - let exists = false; - json!({"path": path, "name": name, "exists": exists}) -} - -fn file_open_recent(s: &mut Session, p: &Value) -> Result { - let index = - p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("file.openRecent", "missing `index`"))?; - let path = s - .prefs - .recent_files - .get(index) - .cloned() - .ok_or_else(|| bad("file.openRecent", format!("no recent file {index} (the list has {})", s.prefs.recent_files.len())))?; - #[cfg(not(target_arch = "wasm32"))] - if !crate::io::is_file(&path) { - return Err(EngineError::Other(format!("{path} can't be found: it was moved, renamed or deleted, or its disk isn't connected"))); - } - file_open(s, &json!({"path": path})) -} - -/// `file.open`: the document opens and moves to the front of File › Open Recent. -fn file_open(s: &mut Session, p: &Value) -> Result { - let r = open_path(s, p)?; - if let Some(path) = str_param(p, "path") { - s.prefs.note_recent_file(path); - } - Ok(r) -} - -fn open_path(s: &mut Session, p: &Value) -> Result { - let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; - #[cfg(not(target_arch = "wasm32"))] - { - let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; - // The content decides first: a file's extension may be missing or wrong. The extension - // then routes files whose content isn't recognised to the reader that explains why. - let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); - let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); - if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) { - return super::interchange::open_idml(s, p); - } - if !native && ext != designcraft_format::EXTENSION { - return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file"))); - } - let (mut d, mut warnings) = load_bytes(&bytes)?; - super::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path))); - super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); - let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path); - warnings.extend(font_warnings); - warnings.extend(super::style::grep_style_warnings(&d)); - let mut st = DocState::new(d, Some(path.to_string())); - st.fonts = fonts; - let i = s.add_document(st); - Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (s, path); - Err(EngineError::Other("use file.openBytes on the web".into())) - } -} - -/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone -/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each -/// font file skipped). Nothing on the web (no folders). -pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option>, usize, Vec) { - #[cfg(not(target_arch = "wasm32"))] - { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; - // A document's folder is the agent's to name (a recovery entry or a book lists paths too): - // the fonts crate asks the automation roots about the folder and each font file, so fonts - // outside the read root stay unloaded. - let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p)); - d.font_scope = r.scope; - let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); - (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (d, path); - (None, 0, Vec::new()) - } -} - -/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it -/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins. -#[cfg(not(target_arch = "wasm32"))] -fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - let exact = dir.join(DOCUMENT_FONTS_FOLDER); - if crate::io::is_dir(&exact) { - return exact; - } - crate::io::read_dir(dir) - .into_iter() - .flatten() - .flatten() - .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) - .map_or(exact, |e| e.path()) -} - -fn file_open_bytes(s: &mut Session, p: &Value) -> Result { - let b = base64_decode(str_param(p, "base64").unwrap_or("")); - // IDML packages are recognised by their stored `mimetype` first entry, INDD files by their - // signature (or their name). - if designcraft_idml::is_idml(&b) || super::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) { - return super::interchange::open_idml(s, p); - } - let (mut d, warnings) = load_bytes(&b)?; - super::datamerge::resolve_sources_on_open(&mut d, None); - if let Some(n) = str_param(p, "name") { - d.title = n.to_string(); - } - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i, "warnings": warnings})) -} - -fn file_save(s: &mut Session, p: &Value) -> Result { - let st = s.doc_mut()?; - // Saving to the document's own file keeps its name, even one opened without the extension. - let path = str_param(p, "path") - .map(with_document_extension) - .or_else(|| st.path.clone()) - .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; - // Before anything about the document changes: a refused save leaves it as it was. - crate::file_access::check_write(&path).map_err(EngineError::Other)?; - // Prepare paths for the destination without changing the live document until persistence succeeds. - let mut saved = (*st.doc).clone(); - super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); - let paths_changed = saved.data_merge != st.doc.data_merge; - let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; - let bytes = to_bytes_with_preview(&saved, &s.cache)?; - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; - let st = s.doc_mut()?; - if paths_changed { - st.doc = saved; - st.bump_revision(); - } - st.path = Some(path.clone()); - st.saved_revision = st.revision; - st.saved_doc = st.doc.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - let recovery_dir = s.recovery_dir.clone(); - let index = s.active_index().ok_or(EngineError::NoDocument)?; - crate::recovery::discard_document(s, index, recovery_dir.as_deref()); - s.prefs.note_recent_file(&path); - } - Ok(json!({"path": path, "bytes": bytes.len()})) -} - -fn file_place(s: &mut Session, p: &Value) -> Result { - // The file's size and time before it is read (a linked text file remembers them). - let mut before = None; - let (bytes, name, link) = if let Some(b) = str_param(p, "base64") { - (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) - } else if let Some(path) = str_param(p, "path") { - before = super::datamerge::fingerprint_from_path(path); - #[cfg(not(target_arch = "wasm32"))] - let b = crate::io::read_file(path).map_err(EngineError::Other)?; - #[cfg(target_arch = "wasm32")] - let b: Vec = vec![]; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - (b, name, Some(path.to_string())) - } else { - s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); - return ok(); - }; - // A page of another layout (IDML or DesignCraft): its objects, as one group. - let lname = name.to_lowercase(); - if lname.ends_with(".idml") || lname.ends_with(".designcraft") { - return place_layout_page(s, p, &name, &bytes); - } - // Keep the EPS source for link comparison; rendering uses its preview at the bounding box size. - let eps_size = eps_size(&bytes, "file.place")?; - // Video and sound: a media frame (Window › Interactive › Media). - if let Some(mime) = designcraft_doc::media_mime(&name) { - return super::media::place_media(s, p, name, mime, bytes, link); - } - // Text files (plain, Word, RTF) flow into frames. - if designcraft_textimport::is_text_file(&name) { - return super::place_text::place_text(s, p, &name, &bytes, link, before); - } - let want_w = p - .get("width") - .filter(|value| !value.is_null()) - .map(|value| { - value - .as_f64() - .filter(|width| width.is_finite() && *width > 0.0) - .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) - }) - .transpose()?; - // Image Import Options: which page of a PDF (1-based). - if designcraft_render::is_pdf(&bytes) { - designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?; - } - let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { - Some(n) if designcraft_render::is_pdf(&bytes) => { - let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); - let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); - let one_based = usize::try_from(n).map_err(|_| invalid_page())?; - if one_based == 0 || one_based > count { - return Err(invalid_page()); - } - let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; - u32::try_from(zero_based).map_err(|_| invalid_page())? - } - _ => 0, - }; - let (pw, ph) = match pdf_page { - 0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?, - n => designcraft_render::pdf_page_size(&bytes, n as usize) - .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) - .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, - }; - // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages - // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. - let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); - // Image Import Options › Crop to: the graphic spans that box of the PDF page. - let is_pdf = designcraft_render::is_pdf(&bytes); - let pdf_crop = match str_param(p, "pdfCrop") { - Some(k) if is_pdf => PdfCrop::from_name(k) - .ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?, - _ => PdfCrop::Crop, - }; - let mime = designcraft_render::image_mime(&bytes).to_string(); - let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; - // The graphic's natural size: the box in points (the whole page when the box can't be found, - // and then the document says so). - let (bw, bh) = match crop_pdf(&mut asset) { - Some(size) => size, - None => { - asset.pdf_crop = PdfCrop::Crop; - (nw, nh) - } - }; - let target_frame = super::id_param(p, "frame").or_else(|| { - if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { - return None; - } - let st = s.active()?; - st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) - }); - // `"spread": null` counts as omitted, like the other placement parameters. - let spread = match p.get("spread") { - Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0), - _ => super::spread_param(p, "file.place")?, - }; - let lid = s.doc()?.active_layer; - let px = p.get("x").and_then(Value::as_f64); - let py = p.get("y").and_then(Value::as_f64); - if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { - return load_cursor(s, asset, (bw, bh)); - } - s.edit(|d, sel| { - let fresh = AssetId(d.alloc()); - let aid = d.add_asset(Asset { id: fresh, ..asset }); - // The width the whole page (or image) gets; the height follows the proportions. - let w = match want_w { - Some(w) => w, - None => { - let page_w = d.settings.page_width * 0.6; - nw.min(page_w) - } - }; - let id = if let Some(fid) = target_frame { - let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; - let r = it.inner_bounds(); - // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. - let k = (r.width() / bw).max(r.height() / bh); - let (gw, gh) = (bw * k, bh * k); - let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); - validate_placement((bw, bh), r, xf, "file.place")?; - it.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (bw, bh), - xf, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - fid - } else { - let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?; - let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0)); - let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)); - let id = ItemId(d.alloc()); - let k = w / nw; - let r = super::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?; - let xf = Affine::translate((x, y)) * Affine::scale(k); - validate_placement((bw, bh), r, xf, "file.place")?; - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); - it.object_style = d.styles.default_graphic_frame.clone(); - if let Some(e) = d.styles.object_effects(&it.object_style) { - it.effects = e.clone(); - } - it.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (bw, bh), - xf, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - d.insert_item(spread, it, None)?; - id - }; - *sel = Selection::items(vec![id]); - Ok(json!({"id": id.0, "asset": aid.0})) - }) -} - -const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -pub fn base64_encode(data: &[u8]) -> String { - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - out.push(B64[(n >> 18) as usize & 63] as char); - out.push(B64[(n >> 12) as usize & 63] as char); - out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); - out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); - } - out -} - -pub fn base64_decode(s: &str) -> Vec { - let mut out = Vec::with_capacity(s.len() * 3 / 4); - let mut buf = 0u32; - let mut bits = 0; - for b in s.bytes() { - let v = match b { - b'A'..=b'Z' => b - b'A', - b'a'..=b'z' => b - b'a' + 26, - b'0'..=b'9' => b - b'0' + 52, - b'+' | b'-' => 62, - b'/' | b'_' => 63, - _ => continue, - } as u32; - buf = buf << 6 | v; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((buf >> bits) as u8); - } - } - out -} - -/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in -/// points. The crop box is the whole page and has none; nor has a page that can't be read, which -/// keeps its crop choice (a missing link relinked later gets its box then). -pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { - a.pdf_box = None; - if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) { - return None; - } - let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?; - a.pdf_box = Some(frac); - Some(size) -} - -pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { - if let Some(b) = str_param(p, "base64") { - return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); - } - let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; - #[cfg(not(target_arch = "wasm32"))] - let b = crate::io::read_file(path).map_err(EngineError::Other)?; - #[cfg(target_arch = "wasm32")] - let b: Vec = vec![]; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - Ok((b, name, Some(path.to_string()))) -} - -fn eps_size(bytes: &[u8], command: &str) -> Result> { - if !designcraft_images::is_eps(bytes) { - return Ok(None); - } - designcraft_images::eps_size(bytes) - .map(Some) - .ok_or_else(|| bad(command, "the EPS has a missing or invalid bounding box (dimensions must be 0.001–20,000 points)")) -} - -/// Fitting can overflow even with finite inputs. Never persist NaN/Inf as JSON nulls. -pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, command: &str) -> Result<()> { - let valid_rect = |r: Rect| [r.x0, r.y0, r.x1, r.y1, r.width(), r.height()].iter().all(|n| n.is_finite()) && r.width() >= 0.0 && r.height() >= 0.0; - if !size.0.is_finite() - || !size.1.is_finite() - || size.0 <= 0.0 - || size.1 <= 0.0 - || !valid_rect(frame) - || !xf.as_coeffs().iter().all(|n| n.is_finite()) - || !valid_rect(xf.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1))) - { - return Err(bad(command, "image placement exceeds finite geometry limits")); - } - Ok(()) -} - -fn place_load(s: &mut Session, p: &Value) -> Result { - let (bytes, name, link) = read_source(p)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; - let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; - load_cursor(s, asset, natural) -} - -/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. -fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result { - let name = asset.name.clone(); - let (pw, ph) = asset.pixels.unwrap_or_default(); - let aid = s.edit(|d, _| { - asset.id = AssetId(d.alloc()); - Ok(d.add_asset(asset)) - })?; - s.loaded = Some(crate::LoadedGraphic { asset: aid, size }); - s.set_tool("placeGun"); - Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) -} - -fn place_drop(s: &mut Session, p: &Value) -> Result { - let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - // Undo can remove the loaded graphic's asset. - if !s.doc()?.doc.assets.contains_key(&aid) { - s.loaded = None; - return Err(bad("place.drop", "the loaded graphic is no longer in the document")); - } - let sr = super::spread_param(p, "place.drop")?; - let lid = s.doc()?.active_layer; - let frame = super::id_param(p, "frame"); - let rect = super::rect_param(p, "rect"); - let (x, y) = (super::f64_or(p, "x", 0.0), super::f64_or(p, "y", 0.0)); - let d = &s.doc()?.doc; - let (frame_rect, xf, auto_fit) = if let Some(fid) = frame { - let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); - let k = (r.width() / nw).max(r.height() / nh); - let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); - // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. - (r, xf, Default::default()) - } else { - if d.spread(sr).is_none() { - return Err(bad("place.drop", "no such spread")); - } - // Drag: fit proportionally; click: actual size at the point. - let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { - let k = (r.width() / nw).min(r.height() / nh); - (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) - } - _ => (Rect::new(x, y, x + nw, y + nh), 1.0), - }; - (r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default()) - }; - validate_placement((nw, nh), frame_rect, xf, "place.drop")?; - let r = s.edit(|d, sel| { - let graphic = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (nw, nh), - xf, - clip: None, - auto_fit, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - let id = if let Some(fid) = frame { - d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic; - fid - } else { - let id = ItemId(d.alloc()); - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect)); - it.object_style = d.styles.default_graphic_frame.clone(); - if let Some(e) = d.styles.object_effects(&it.object_style) { - it.effects = e.clone(); - } - it.content = graphic; - d.insert_item(sr, it, None)?; - id - }; - *sel = Selection::items(vec![id]); - Ok(json!({"id": id.0})) - })?; - s.loaded = None; - s.set_tool("selection"); - Ok(r) -} - -/// The selection as `.designcraft` snippet bytes. -pub(crate) fn snippet_bytes(s: &mut Session) -> Result> { - to_bytes(&super::edit::clip_doc(s)?) -} - -fn snippet_export(s: &mut Session, p: &Value) -> Result { - let d = super::edit::clip_doc(s)?; - let bytes = to_bytes(&d)?; - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) - } - None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), - } -} - -/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, -/// 1-based) grouped, top-left at x/y (default: the source position on the first page). -fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { - const ID: &str = "file.place"; - let src = if name.to_lowercase().ends_with(".idml") { - super::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))? - } else { - from_bytes(bytes)? - }; - let n = src.page_count(); - let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize; - if page == 0 || page > n { - return Err(bad(ID, format!("the document has {n} page(s)"))); - } - let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?; - let sp = &src.spreads[si]; - let pr = sp.pages[pi].bounds(); - let ids: Vec = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect(); - if ids.is_empty() { - return Err(bad(ID, format!("page {page} has no objects"))); - } - let to = super::spread_param(p, ID)?; - let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), - _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), - }; - let placed = s.edit(|d, sel| { - for sw in &src.swatches { - if d.swatch(&sw.name).is_none() { - d.swatches.push(sw.clone()); - } - } - let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); - let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); - if !missing_p.is_empty() || !missing_c.is_empty() { - let st = d.styles_mut(); - st.paragraph.extend(missing_p); - st.character.extend(missing_c); - } - let new = super::object::duplicate_from(d, &src, &ids, to, off)?; - *sel = Selection::items(new.clone()); - Ok(new) - })?; - if placed.len() > 1 { - let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::>()}))?; - return Ok(json!({"id": g["id"], "items": placed.len()})); - } - Ok(json!({"id": placed[0].0, "items": 1})) -} - -pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { - let (bytes, _, _) = read_source(p)?; - let src = from_bytes(&bytes)?; - let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); - if ids.is_empty() { - return Err(bad("snippet.place", "the snippet is empty")); - } - let sr = super::spread_param(p, "snippet.place")?; - let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); - let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), - _ => designcraft_geom::Vec2::ZERO, - }; - s.edit(|d, sel| { - // Bring over styles and swatches the snippet uses that this document lacks. - for sw in &src.swatches { - if d.swatch(&sw.name).is_none() { - d.swatches.push(sw.clone()); - } - } - let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); - let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); - if !missing_p.is_empty() || !missing_c.is_empty() { - let st = d.styles_mut(); - st.paragraph.extend(missing_p); - st.character.extend(missing_c); - } - let new = super::object::duplicate_from(d, &src, &ids, sr, off)?; - *sel = Selection::items(new.clone()); - Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) - }) -} - -/// Which access a recovery command needs to the folder it is given. -#[derive(Clone, Copy)] -enum FolderAccess { - Read, - Write, - ReadWrite, -} - -/// The recovery folder: `dir` when the command names one, else the session's own (set by the -/// host application, so not the automation roots' business). A named folder is checked against -/// the roots for `access`. -fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { - #[cfg(target_arch = "wasm32")] - { - let _ = (s, p, access); - Err(EngineError::Other("no recovery folder on the web".into())) - } - #[cfg(not(target_arch = "wasm32"))] - { - let Some(named) = str_param(p, "dir") else { - return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); - }; - if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { - crate::file_access::check_read(named).map_err(EngineError::Other)?; - } - if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { - crate::file_access::check_write(named).map_err(EngineError::Other)?; - } - Ok(std::path::PathBuf::from(named)) - } -} - -fn can_revert(s: &Session) -> std::result::Result<(), String> { - match s.active() { - Some(d) if d.path.is_some() && d.is_dirty() => Ok(()), - Some(d) if d.path.is_none() => Err("the document has never been saved".into()), - Some(_) => Err("no changes since the last save".into()), - None => Err("no document open".into()), - } -} - -fn file_revert(s: &mut Session, _: &Value) -> Result { - let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - #[cfg(not(target_arch = "wasm32"))] - { - let mut d = if path.to_ascii_lowercase().ends_with(".idml") { - let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; - super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))? - } else { - from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? - }; - // As opened again: the fonts beside it now (unchanged files are shared, not read again). - let (fonts, _, _) = load_document_fonts(&mut d, &path); - let uid = s.doc()?.uid; - let recovery_dir = s.recovery_dir.clone(); - let index = s.active_index().ok_or(EngineError::NoDocument)?; - let mut fresh = DocState::new(d, Some(path.clone())); - fresh.fonts = fonts; - fresh.uid = uid; - // A new state has a revision no state had before, so caches don't show the old document. - crate::recovery::discard_document(s, index, recovery_dir.as_deref()); - *s.doc_mut()? = fresh; - Ok(json!({"path": path})) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (s, path); - Err(EngineError::Other("revert isn't available on the web".into())) - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod image_place_tests { - use designcraft_doc::{Content, ItemId}; - use designcraft_geom::Rect; - use serde_json::{Value, json}; - - use crate::Session; - - pub(super) fn image_file() -> std::io::Result { - image_file_with_color(30) - } - - fn image_file_with_color(red: u8) -> std::io::Result { - let file = tempfile::Builder::new().suffix(".png").tempfile()?; - let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); - std::fs::write(file.path(), png)?; - Ok(file) - } - - #[test] - fn consecutive_path_placements_create_distinct_graphics() { - let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; - let mut session = Session::new(); - session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - let mut placements = Vec::new(); - let mut originals = Vec::new(); - - for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { - let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); - let id = ItemId(placed["id"].as_u64().unwrap()); - assert!( - placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), - "each explicit placement needs a distinct frame: {placed}" - ); - let document = &session.doc().unwrap().doc; - let item = document.item(id).unwrap(); - assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); - assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); - originals.push(serde_json::to_value(item).unwrap()); - placements.push(placed); - } - - let document = &session.doc().unwrap().doc; - assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); - assert_eq!(document.assets.len(), 3); - for (placed, original) in placements.iter().zip(originals) { - assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); - } - let links = session.execute("links.list", &json!({})).unwrap(); - assert_eq!(links.as_array().unwrap().len(), 3); - for (placed, image) in placements.into_iter().zip(&images) { - let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); - assert_eq!(row["path"], json!(image.path())); - assert_eq!(row["uses"].as_array().unwrap().len(), 1); - assert_eq!(row["uses"][0]["id"], placed["id"]); - } - } - - #[test] - fn individual_placement_parameters_bypass_selected_frames() { - let image = image_file().unwrap(); - for graphic in [false, true] { - for (key, value, expected) in [ - ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), - ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), - ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), - ("width", 0, Rect::ZERO), - ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), - ] { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - if graphic { - session.execute("file.place", &json!({"path": image.path()})).unwrap(); - } - let id = ItemId(selected["id"].as_u64().unwrap()); - let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); - let mut params = json!({"path": image.path()}); - params[key] = json!(value); - - let result = session.execute("file.place", ¶ms); - if key == "width" && value == 0 { - assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); - continue; - } - let placed = result.unwrap(); - - assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); - let document = &session.doc().unwrap().doc; - assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); - let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), expected); - let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; - let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); - assert!(scale_x > 0.0 && scale_y > 0.0); - } - } - } - - #[test] - fn explicit_frame_wins_over_placement_and_selection() { - let image = image_file().unwrap(); - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); - - let placed = - session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); - - assert_eq!(placed["id"], target["id"]); - let document = &session.doc().unwrap().doc; - let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - assert!(matches!(item.content, Content::Graphic(_))); - assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); - assert_eq!(document.spreads[0].items.len(), 2); - } - - fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { - let image = image_file()?; - for graphic in [false, true] { - for explicit_frame in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({}))?; - let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; - if graphic { - session.execute("file.place", &json!({"path": image.path()}))?; - } - let mut params = json!({"path": image.path(), "width": width}); - if explicit_frame { - params["frame"] = target["id"].clone(); - session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; - } - let before = session.doc()?; - let document = before.doc.clone(); - let selection = before.selection.clone(); - let revision = before.revision; - let assets = document.assets.len(); - - let result = session.execute("file.place", ¶ms); - - assert!( - matches!(&result, Err(crate::EngineError::BadParams { .. })), - "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" - ); - let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; - let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; - assert_eq!(cmd, "file.place"); - assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); - let after = session.doc()?; - assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); - assert_eq!(after.selection, selection); - assert_eq!(after.revision, revision); - assert_eq!(after.doc.assets.len(), assets); - } - } - Ok(()) - } - - #[test] - fn width_rejected_when_zero() { - assert_width_rejected(json!(0)).unwrap(); - } - - #[test] - fn width_rejected_when_negative() { - assert_width_rejected(json!(-80)).unwrap(); - } - - #[test] - fn width_rejected_when_non_numeric() { - for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { - assert_width_rejected(width).unwrap(); - } - } - - fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { - let image = image_file()?; - let previous_image = image_file_with_color(31)?; - for graphic in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({}))?; - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; - let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; - let mut params = json!({"path": image.path()}); - for key in keys { - params[*key] = Value::Null; - } - - let placed = session.execute("file.place", ¶ms)?; - - assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); - if let Some(previous) = previous { - assert_ne!(placed["asset"], previous["asset"]); - } - let document = &session.doc()?.doc; - let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); - let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); - let Content::Graphic(content) = &item.content else { - return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); - }; - assert_eq!(json!(content.asset.0), placed["asset"]); - assert_eq!(document.spreads[0].items.len(), 1); - } - Ok(()) - } - - #[test] - fn selection_used_when_x_null() { - assert_null_placement_uses_selection(&["x"]).unwrap(); - } - - #[test] - fn selection_used_when_y_null() { - assert_null_placement_uses_selection(&["y"]).unwrap(); - } - - #[test] - fn selection_used_when_spread_null() { - assert_null_placement_uses_selection(&["spread"]).unwrap(); - } - - #[test] - fn selection_used_when_width_null() { - assert_null_placement_uses_selection(&["width"]).unwrap(); - } - - #[test] - fn selection_used_when_all_placement_parameters_null() { - assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); - } - - #[test] - fn implicit_placement_fills_empty_and_replaces_graphic_selection() { - let image = image_file().unwrap(); - let previous_image = image_file_with_color(31).unwrap(); - for graphic in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); - - let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); - - assert_eq!(placed["id"], selected["id"]); - if let Some(previous) = previous { - assert_ne!(placed["asset"], previous["asset"]); - } - let document = &session.doc().unwrap().doc; - let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; - assert_eq!(json!(content.asset.0), placed["asset"]); - assert_eq!(document.spreads[0].items.len(), 1); - } - } -} - -#[cfg(test)] -mod new_document_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn saved_presets_make_documents_by_name() { - let mut s = Session::new(); - let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277, - "pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50}, - "bleed": [9, 9, 0, 9], "primaryTextFrame": true}); - assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter"); - let listed = s.execute("file.presets", &json!({})).unwrap(); - let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect(); - assert_eq!(saved.len(), 1); - assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters"))); - // By name, case ignored; parameters still override the preset. - s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_count(), 4); - assert!(!d.settings.facing_pages && d.settings.primary_text_frame); - assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters); - assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]); - assert_eq!(d.page(0).unwrap().margins.outside, 50.0); - assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's"); - // The same name replaces it. - s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap(); - assert_eq!(s.prefs.document_presets.len(), 1); - s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.page_count(), 2); - // Built-in names, empty and overlong names and impossible sizes are refused. - for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})] - { - assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}"); - } - s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap(); - assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err()); - assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err()); - } - - #[test] - fn new_document_takes_intent_units_start_page_bleed_and_slug() { - let mut s = Session::new(); - let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18}); - s.execute("file.new", &p).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.settings.intent, designcraft_doc::Intent::Web); - assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels); - assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]); - assert_eq!(d.settings.slug, [18.0; 4]); - assert_eq!(d.page_name(0), "4"); - // Malformed edges are ignored, hostile ones bounded. - s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.settings.bleed, [0.0; 4]); - assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); - } - - #[test] - fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() { - let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id())); - let mut s = Session::new(); - s.recovery_dir = Some(dir.clone()); - s.execute("file.new", &json!({})).unwrap(); - let first = s.doc().unwrap().uid; - let a = s.preview_new_document(&json!({"width": 300})).unwrap(); - assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2")); - let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap(); - assert_eq!(s.documents().len(), 2, "the preview replaces itself"); - assert!(s.preview_new_document(&json!({"width": 0})).is_err()); - let d = s.doc().unwrap(); - assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it"); - let listed = s.execute("document.list", &json!({})).unwrap(); - assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true))); - assert_eq!(s.end_new_document_preview(), Some(b)); - let d = s.doc().unwrap(); - assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0)); - assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing"); - // Edited by a script while shown, it writes no recovery data while it is a preview, and - // stays as a document when the window closes, with its Untitled number. - let c = s.preview_new_document(&json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0); - // The app's timer saves through the command. - assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 0); - assert!(crate::recovery::list(&dir).is_empty()); - assert_eq!(s.end_new_document_preview(), None); - assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2")); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.title, "Untitled-3"); - // Create with the previewed settings keeps the preview as the document. - let d = s.preview_new_document(&json!({"pages": 2})).unwrap(); - assert_eq!(s.keep_new_document_preview(), Some(3)); - let st = s.doc().unwrap(); - assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None)); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.title, "Untitled-5"); - let _ = std::fs::remove_dir_all(&dir); - } -} - #[cfg(test)] pub(crate) mod eps_place_tests; - -#[cfg(test)] -mod pdf_crop_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn place_pdf_cropped_to_its_trim_box() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); - let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - let size = |t: &mut Session, crop: &str| { - t.execute("edit.deselectAll", &json!({})).unwrap(); - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": crop, "width": 630, "x": 0, "y": 0})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - t.doc().unwrap().doc.item(id).unwrap().bounds() - }; - let full = size(&mut t, "crop"); - let trim = size(&mut t, "trim"); - assert!((full.width() - 630.0).abs() < 1e-6, "{full:?}"); - assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); - assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); - } - - /// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it, - /// even in a frame larger than the graphic. - #[test] - fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() { - // Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); - for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] { - let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); - } - let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); - - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - // `width` is the whole page's (630 pt with its bleed): half size. - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let d = t.doc().unwrap().doc.clone(); - let it = d.item(id).unwrap(); - let g = it.graphic().unwrap().clone(); - let a = &d.assets[&g.asset]; - assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim); - let b = a.shown_box().unwrap(); - let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0]; - assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}"); - assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size); - let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); - let fb = it.bounds(); - for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] { - assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}"); - } - - // A frame larger than the graphic still shows only the trim box. - t.edit(|d, _| { - d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0)); - Ok(()) - }) - .unwrap(); - let d = t.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap(); - let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60; - // The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows - // where it is on the page. - assert!(!dark(97, 97), "the bleed is outside the trim box"); - assert!(!dark(103, 103), "the page is offset by its bleed"); - assert!(dark(110, 110), "the square inside the trim box"); - } - - #[test] - fn placed_pdf_keeps_its_exact_page_size() { - // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); - let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let d = &t.doc().unwrap().doc; - let it = d.item(id).unwrap(); - let b = it.bounds(); - assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); - let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; - assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); - } -} - -#[cfg(test)] -mod layout_place_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn place_a_page_of_an_idml_file_as_a_group() { - let mut a = Session::new(); - a.execute("file.new", &json!({"pages": 2})).unwrap(); - a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap(); - a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap(); - let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc); - let b64 = super::base64_encode(&idml); - let mut b = Session::new(); - b.execute("file.new", &json!({})).unwrap(); - let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); - assert_eq!(r["items"], 2); - let d = &b.doc().unwrap().doc; - let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap(); - assert_eq!(g.children().len(), 2, "one group"); - let bb = g.bounds(); - assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}"); - assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along"); - assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); - } -} - -#[cfg(test)] -mod save_tests { - use serde_json::json; - - use crate::Session; - - /// Saving over an existing document replaces it whole and leaves nothing beside it. - #[test] - fn save_replaces_the_existing_file_without_leftovers() { - let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); - let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); - assert!(saved.stories.values().any(|st| st.text == "Saved whole")); - let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); - assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); - // A folder that doesn't exist: an error naming the path. - let missing = dir.join("missing").join("doc.designcraft"); - let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); - assert!(e.to_string().contains("missing"), "{e}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A document that can't be written is an error: the file on disk, the unsaved state and the - /// recovery checkpoint all stay as they were. - #[test] - fn a_save_that_cannot_write_the_document_changes_nothing() { - let dir = std::env::temp_dir().join(format!("dc-save-fails-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - let recovery = dir.join("recovery"); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - let before = std::fs::read(&path).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Unsaved", "caret": false})).unwrap(); - s.execute("file.recovery.save", &json!({"dir": recovery.to_string_lossy()})).unwrap(); - let checkpoint = recovery.join(format!("{}.designcraft", s.doc().unwrap().uid)); - assert!(checkpoint.exists()); - - super::FAIL_TO_BYTES.with(|f| f.set(true)); - let save = s.execute("file.save", &json!({})); - let copy = s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})); - let save_as = s.execute("file.saveAs", &json!({"path": dir.join("other.designcraft").to_string_lossy()})); - let serialized = s.execute("file.serialize", &json!({})); - super::FAIL_TO_BYTES.with(|f| f.set(false)); - - let e = save.unwrap_err(); - assert!(e.to_string().contains("could not write the document"), "{e}"); - assert!(copy.is_err() && serialized.is_err(), "no empty copy and no empty download"); - assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); - assert!(!dir.join("copy.designcraft").exists()); - assert!(save_as.is_err() && !dir.join("other.designcraft").exists()); - assert!(!s.prefs.recent_files.iter().any(|p| p.ends_with("other.designcraft")), "a failed Save As isn't a recent file"); - assert!(s.doc().unwrap().is_dirty(), "the document still has unsaved changes"); - assert!(checkpoint.exists(), "the recovery checkpoint stays"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1050: a placement whose coordinates overflow to infinity is refused, and Save refuses a - /// document holding a non-finite rect anyway, leaving the file on disk as it was. - #[test] - fn non_finite_coordinates_are_refused_and_never_saved() { - let image = super::image_place_tests::image_file().unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let e = s.execute("file.place", &json!({"path": image.path(), "x": 1e308, "width": 1e308})).unwrap_err(); - assert!(e.to_string().contains("finite"), "{e}"); - assert!(s.doc().unwrap().doc.all_items().is_empty(), "nothing is placed"); - - let dir = std::env::temp_dir().join(format!("dc-save-non-finite-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - let before = std::fs::read(&path).unwrap(); - let id = designcraft_doc::ItemId(s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap()["id"].as_u64().unwrap()); - let st = s.doc_mut().unwrap(); - let infinite = designcraft_geom::Rect::new(36.0, 36.0, f64::INFINITY, 200.0); - std::sync::Arc::make_mut(&mut st.doc).item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(infinite); - let e = s.execute("file.save", &json!({})).unwrap_err(); - assert!(e.to_string().contains("not a finite number"), "{e}"); - assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); - assert!(s.doc().unwrap().is_dirty()); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod document_fonts_tests { - use std::path::{Path, PathBuf}; - - use designcraft_fonts::testing::font_with; - use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource}; - use serde_json::{Value, json}; - - use crate::Session; - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap(); - dir - } - - /// A new document in `s` with a text frame holding `text` set in `family`. - fn new_document(s: &mut Session, family: &str, text: &str) { - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); - s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); - } - - /// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML). - fn write_document(path: &Path, family: &str) { - write_document_with(path, family, "Hello"); - } - - fn write_document_with(path: &Path, family: &str, text: &str) { - let mut s = Session::new(); - new_document(&mut s, family, text); - if path.extension().is_some_and(|e| e == "idml") { - std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - } else { - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - } - } - - fn font_entry(s: &mut Session, family: &str) -> Value { - let l = s.execute("font.list", &json!({})).unwrap(); - l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) - } - - /// Each character of the active document's text with the face it is drawn in. - fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> { - let st = s.doc().unwrap(); - let mut out = Vec::new(); - for (sid, story) in &st.doc.stories { - let cs = s.cache.get(&st.doc, *sid, None); - for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) { - out.push((story.text[g.byte..].chars().next().unwrap(), g.face)); - } - } - out - } - - fn open(s: &mut Session, path: &Path) -> Value { - s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap() - } - - #[test] - fn opening_a_document_loads_its_document_fonts() { - const FAMILY: &str = "DocFont Open Test"; - let dir = temp_dir("designcraft"); - let fonts = dir.join(DOCUMENT_FONTS_FOLDER); - std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap(); - let path = dir.join("Brochure.designcraft"); - write_document(&path, FAMILY); - assert!(!FontDb::global().has_family(FAMILY)); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}"); - let f = font_entry(&mut s, FAMILY); - assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn opening_an_idml_file_loads_its_document_fonts() { - const FAMILY: &str = "DocFont IDML Test"; - let dir = temp_dir("idml"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); - let path = dir.join("Brochure.idml"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn an_indesign_file_says_how_to_bring_it_over() { - // The extension decides (any case), not the bytes: no InDesign-made file is needed. - let dir = temp_dir("indd"); - let path = dir.join("Brochure.INDD"); - std::fs::write(&path, b"not a DesignCraft document").unwrap(); - let mut s = Session::new(); - let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); - assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); - let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); - assert!(e.contains("InDesign Markup (IDML)"), "{e}"); - assert!(s.doc().is_err(), "nothing was opened"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_document_fonts_folder_is_found_whatever_its_case() { - const FAMILY: &str = "DocFont Case Test"; - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let fonts = dir.join("Document fonts"); - std::fs::create_dir_all(&fonts).unwrap(); - std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); - let path = dir.join("Brochure.idml"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - let r = open(&mut s, &path); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_document_without_the_folder_opens_as_before() { - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("Plain.designcraft"); - write_document(&path, designcraft_fonts::DEFAULT_FAMILY); - let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] - fn document_fonts_belong_to_their_document() { - const FAMILY: &str = "DocFont Scoped"; - let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b")); - std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - write_document(&a.join("A.designcraft"), FAMILY); - write_document(&b.join("B.designcraft"), FAMILY); - - let mut s = Session::new(); - assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1); - assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0); - // B, open at the same time, doesn't have A's font. - assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s)); - s.execute("file.activate", &json!({"index": 0})).unwrap(); - assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s)); - let _ = std::fs::remove_dir_all(&a); - let _ = std::fs::remove_dir_all(&b); - } - - #[test] - fn each_document_sets_its_own_font_of_a_name() { - const FAMILY: &str = "DocFont Same"; - let (a, b) = (temp_dir("same-a"), temp_dir("same-b")); - let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf")); - // A's font has both letters, B's only `b`. - std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap(); - std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap(); - // The same words in both: the word cache must not hand one document's shaping to the other. - write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab"); - write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab"); - - let mut s = Session::new(); - open(&mut s, &a.join("A.designcraft")); - open(&mut s, &b.join("B.designcraft")); - // Fallback fonts for the `a` B's font lacks: never A's font. - s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); - let in_b = glyph_faces(&s); - s.execute("file.activate", &json!({"index": 0})).unwrap(); - let in_a = glyph_faces(&s); - assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}"); - for (c, f) in &in_a { - assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A"); - } - for (c, f) in &in_b { - match c { - 'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"), - _ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"), - } - } - let _ = std::fs::remove_dir_all(&a); - let _ = std::fs::remove_dir_all(&b); - } - - #[test] - fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() { - const FAMILY: &str = "DocFont Closed"; - let dir = temp_dir("closed"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Closed.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - let first = glyph_faces(&s)[0].1; - assert_eq!(first.family, FAMILY); - s.execute("file.close", &json!({})).unwrap(); - // A new document can't use the closed document's font. - new_document(&mut s, FAMILY, "Hello"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY)); - // Reopened, the document has its font again: the face loaded the first time. - assert_eq!(open(&mut s, &path)["documentFonts"], 1); - assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_reverted_document_keeps_its_fonts() { - const FAMILY: &str = "DocFont Reverted"; - let dir = temp_dir("revert"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Revert.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap(); - s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn bold_and_italic_link_the_document_fonts_styles() { - const FAMILY: &str = "DocFont Linked"; - let dir = temp_dir("linked"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Linked.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - // The family is the document's alone: its Regular is the style without bold or italic. - let r = s.execute("type.bold", &json!({"on": false})).unwrap(); - assert_eq!(r["fontStyle"], "Regular", "{r}"); - let r = s.execute("type.italic", &json!({"on": false})).unwrap(); - assert_eq!(r["fontStyle"], "Regular", "{r}"); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod extension_tests { - use serde_json::json; - - use super::{is_document, with_document_extension}; - use crate::Session; - - #[test] - fn the_document_extension_is_added_when_missing_or_different() { - assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft"); - assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft"); - assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft"); - assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft"); - assert_eq!(with_document_extension("/a/.."), "/a/.."); - } - - #[test] - fn save_adds_the_extension_and_open_recognises_files_by_content() { - let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - - let bare = dir.join("Brochure"); - let written = dir.join("Brochure.designcraft"); - let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap(); - assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}"); - assert!(written.is_file() && !bare.exists()); - assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str()); - let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap(); - assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}"); - - // A native file without the extension opens, and Save writes back to that same file. - std::fs::rename(&written, &bare).unwrap(); - let mut t = Session::new(); - t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap(); - let r = t.execute("file.save", &json!({})).unwrap(); - assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}"); - assert!(!written.exists()); - - // An IDML package without the extension opens as IDML, unsaved. - let idml = dir.join("Package"); - std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap())); - // The start of the file is enough. - for path in [&idml, &bare] { - let bytes = std::fs::read(path).unwrap(); - assert!(is_document(&bytes[..bytes.len().min(128)]), "{path:?}"); - } - t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap(); - assert_eq!(t.doc().unwrap().path, None); - - let notes = dir.join("Notes"); - std::fs::write(¬es, b"plain text").unwrap(); - assert!(!is_document(b"plain text")); - let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err(); - assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}"); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod recent_tests { - use serde_json::json; - - use crate::Session; - - fn temp_dir(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("dc-recent-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn saved(s: &mut Session, path: &std::path::Path) -> String { - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - path.to_string_lossy().to_string() - } - - /// Opening and saving put a file at the front once; Save a Copy doesn't count. - #[test] - fn open_and_save_order_the_list_without_duplicates() { - let dir = temp_dir("order"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let b = saved(&mut s, &dir.join("b.designcraft")); - assert_eq!(s.prefs.recent_files, [b.clone(), a.clone()]); - s.execute("file.open", &json!({"path": a})).unwrap(); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "reopening moves it to the front, once"); - s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a copy isn't a recent file"); - let rows = s.execute("file.recentFiles", &json!({})).unwrap(); - assert_eq!(rows[0], json!({"path": a, "name": "a.designcraft", "exists": true})); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn open_recent_reopens_and_a_missing_file_is_an_error_that_keeps_it_listed() { - let dir = temp_dir("open"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let b = saved(&mut s, &dir.join("b.designcraft")); - let r = s.execute("file.openRecent", &json!({"index": 1})).unwrap(); - assert_eq!(s.documents()[r["index"].as_u64().unwrap() as usize].path.as_deref(), Some(a.as_str())); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()]); - std::fs::remove_file(&b).unwrap(); - let e = s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); - assert!(e.to_string().contains("can't be found"), "{e}"); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a disk that isn't connected comes back"); - assert_eq!(s.execute("file.recentFiles", &json!({})).unwrap()[1]["exists"], false); - // Hostile indices are errors, never panics. - for index in [json!(2), json!(u64::MAX), json!(-1), json!("0"), json!(null)] { - assert!(s.execute("file.openRecent", &json!({"index": index})).is_err(), "{index}"); - } - s.execute("file.clearRecent", &json!({})).unwrap(); - assert!(s.prefs.recent_files.is_empty()); - assert!(s.execute("file.openRecent", &json!({"index": 0})).is_err(), "disabled when empty"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Saving puts a small picture of the first page in the file, which the start screen reads - /// without opening the document. - #[test] - fn saved_files_carry_a_first_page_preview() { - let dir = temp_dir("preview"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let p = super::recent_preview(&a).expect("a preview"); - assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32); - assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize); - assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent"); - let copy = dir.join("copy.designcraft"); - s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap(); - assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too"); - // Files without one (saved before previews), missing files and other files have none. - let old = dir.join("old.designcraft"); - std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); - for path in [old, dir.join("gone.designcraft"), dir.clone()] { - assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_list_keeps_at_most_the_preferred_count() { - let mut s = Session::new(); - for i in 0..30 { - s.prefs.note_recent_file(&format!("/work/{i}.designcraft")); - } - assert_eq!(s.prefs.recent_files.len(), 20, "default: 20"); - assert_eq!(s.prefs.recent_files[0], "/work/29.designcraft"); - s.execute("prefs.set", &json!({"recentFilesMax": 3})).unwrap(); - assert_eq!(s.prefs.recent_files, ["/work/29.designcraft", "/work/28.designcraft", "/work/27.designcraft"]); - assert_eq!(s.execute("prefs.set", &json!({"recentFilesMax": 4_000_000_000u64})).unwrap()["recentFilesMax"], crate::MAX_RECENT_FILES); - assert!(s.execute("prefs.set", &json!({"recentFilesMax": -1})).is_err()); - s.execute("prefs.set", &json!({"recentFilesMax": 0})).unwrap(); - s.prefs.note_recent_file("/work/new.designcraft"); - assert!(s.prefs.recent_files.is_empty(), "0 keeps no list"); - } - - /// The list is a preference: it survives the round trip through prefs.json. - #[test] - fn the_list_survives_a_restart() { - let mut s = Session::new(); - s.prefs.note_recent_file("/work/a.designcraft"); - let saved = serde_json::to_vec(&s.prefs).unwrap(); - let back: crate::Prefs = serde_json::from_slice(&saved).unwrap(); - assert_eq!(back.recent_files, ["/work/a.designcraft"]); - assert_eq!(back.recent_files_max, 20); - let old: crate::Prefs = serde_json::from_slice(br#"{"recoveryMinutes": 2.0}"#).unwrap(); - assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); - } -} - -#[cfg(test)] -mod hyphenation_default_tests { - use std::sync::Arc; - - use designcraft_doc::BASIC_PARAGRAPH; - use designcraft_doc::build::NewDocument; - use serde_json::json; - - use crate::Session; - - fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { - let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); - (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) - } - - #[test] - fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); - - let mut d = designcraft_doc::Document::new(&NewDocument::default()); - let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); - st.para.hyph_capitalized = Some(true); - st.para.hyph_last_word = Some(true); - st.para.hyph_across_column = Some(true); - let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); - s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); - assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); - } -} diff --git a/crates/engine/src/cmd/file/bytes.rs b/crates/engine/src/cmd/file/bytes.rs new file mode 100644 index 0000000000..d211052068 --- /dev/null +++ b/crates/engine/src/cmd/file/bytes.rs @@ -0,0 +1,163 @@ +use super::*; + +/// The document as `.designcraft` bytes, or an error when it can't be written; never an empty file. +pub fn to_bytes(d: &Document) -> Result> { + save_bytes(d, None) +} + +/// [`to_bytes`] with `preview` (PNG bytes) stored as the file's first-page picture. +fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { + #[cfg(test)] + if FAIL_TO_BYTES.with(std::cell::Cell::get) { + return Err(EngineError::Other("could not write the document: injected failure".into())); + } + designcraft_format::save_with_preview(d, preview).map_err(|e| EngineError::Other(format!("could not write the document: {e}"))) +} + +/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so +/// the file opens from the file manager. A path without a file name is left for the write to fail. +pub fn with_document_extension(path: &str) -> String { + let p = std::path::Path::new(path); + let ext = p.extension().and_then(std::ffi::OsStr::to_str); + if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { + return path.to_string(); + } + format!("{path}.{}", designcraft_format::EXTENSION) +} + +/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. +/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their +/// `mimetype` entry, as both formats write them. +pub fn is_document(bytes: &[u8]) -> bool { + designcraft_idml::is_idml(bytes) || designcraft_format::starts_designcraft(bytes) || designcraft_format::is_designcraft(bytes) +} + +/// How much of a file [`is_document`] needs to recognise a package by its `mimetype` entry. +pub const DOCUMENT_SNIFF_BYTES: u64 = 64 << 10; + +/// The longer side of the first-page picture saved in the file, in pixels. +pub(super) const PREVIEW_PX: f64 = 320.0; + +/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file +/// in its recent files. A page that can't be drawn just leaves the picture out. +pub(super) fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Result> { + let preview = d.page_loc(0).and_then(|(si, pi)| { + let b = d.spreads.get(si)?.pages.get(pi)?.bounds(); + let side = b.width().max(b.height()); + (side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side) + }); + let png = + preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png()); + save_bytes(d, png.as_deref()) +} + +pub fn from_bytes(b: &[u8]) -> Result { + load_bytes(b).map(|(d, _)| d) +} + +/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the +/// open, with a warning for each repair. +pub(super) fn load_bytes(b: &[u8]) -> Result<(Document, Vec)> { + let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?; + let warnings = d.fix_parent_bases(); + Ok((d, warnings)) +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +pub fn base64_decode(s: &str) -> Vec { + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let mut buf = 0u32; + let mut bits = 0; + for b in s.bytes() { + let v = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => continue, + } as u32; + buf = buf << 6 | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + out +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod extension_tests { + use serde_json::json; + + use super::{is_document, with_document_extension}; + use crate::Session; + + #[test] + fn the_document_extension_is_added_when_missing_or_different() { + assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft"); + assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft"); + assert_eq!(with_document_extension("/a/.."), "/a/.."); + } + + #[test] + fn save_adds_the_extension_and_open_recognises_files_by_content() { + let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + + let bare = dir.join("Brochure"); + let written = dir.join("Brochure.designcraft"); + let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}"); + assert!(written.is_file() && !bare.exists()); + assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str()); + let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}"); + + // A native file without the extension opens, and Save writes back to that same file. + std::fs::rename(&written, &bare).unwrap(); + let mut t = Session::new(); + t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap(); + let r = t.execute("file.save", &json!({})).unwrap(); + assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}"); + assert!(!written.exists()); + + // An IDML package without the extension opens as IDML, unsaved. + let idml = dir.join("Package"); + std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap())); + // The start of the file is enough. + for path in [&idml, &bare] { + let bytes = std::fs::read(path).unwrap(); + assert!(is_document(&bytes[..bytes.len().min(128)]), "{path:?}"); + } + t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap(); + assert_eq!(t.doc().unwrap().path, None); + + let notes = dir.join("Notes"); + std::fs::write(¬es, b"plain text").unwrap(); + assert!(!is_document(b"plain text")); + let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/file/eps_place_tests.rs b/crates/engine/src/cmd/file/eps_place_tests.rs index 42d3dd7743..2501a31d32 100644 --- a/crates/engine/src/cmd/file/eps_place_tests.rs +++ b/crates/engine/src/cmd/file/eps_place_tests.rs @@ -1,7 +1,7 @@ //! Placing EPS artwork: the source is kept and only the preview is rasterised. //! //! These helpers write real files, so they live in their own `_tests.rs` file: the -//! file-system guard in `tests_file_guard.rs` checks `src` files but exempts test ones. +//! file-system guard in `tests/file_guard.rs` checks `src` files but exempts test ones. use super::{Arc, AssetId, Content, ItemId, Session, base64_encode, json}; diff --git a/crates/engine/src/cmd/file/new.rs b/crates/engine/src/cmd/file/new.rs new file mode 100644 index 0000000000..5847442d3a --- /dev/null +++ b/crates/engine/src/cmd/file/new.rs @@ -0,0 +1,338 @@ +#[cfg(test)] +use super::bytes::base64_encode; +use super::*; + +/// The longest saved preset name, and how many presets are kept. +const MAX_PRESET_NAME: usize = 100; + +const MAX_PRESETS: usize = 500; + +/// A built-in document preset or a saved one, by name (case ignored). +fn find_preset(s: &Session, name: &str) -> Option { + NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned()) +} + +/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +fn new_document_settings(s: &Session, p: &Value) -> Result { + let mut nd = match str_param(p, "preset") { + Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, + None => NewDocument::default(), + }; + apply_settings(&mut nd, p); + check_size("file.new", &nd)?; + Ok(nd) +} + +/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a +/// paragraph's last word or across a column. Opened and imported files keep their own values, +/// which is why `ParaProps` keeps the IDML defaults (on). +fn new_document(nd: &NewDocument) -> Document { + let mut d = Document::new(nd); + if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { + st.para.hyph_capitalized = Some(false); + st.para.hyph_last_word = Some(false); + st.para.hyph_across_column = Some(false); + } + d +} + +pub(super) fn file_new(s: &mut Session, p: &Value) -> Result { + let mut nd = new_document_settings(s, p)?; + s.untitled += 1; + nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); + let d = new_document(&nd); + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i})) +} + +impl Session { + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while + /// the New Document window is open. It replaces the previous preview document in its tab and + /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses + /// leave the previous preview as it was. Returns the preview document's uid. + pub fn preview_new_document(&mut self, p: &Value) -> Result { + let mut nd = new_document_settings(self, p)?; + let previous = match self.new_document_preview { + Some((_, previous)) => previous, + None => self.active().map(|d| d.uid), + }; + let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); + // A preview edited or saved by a menu or script while the window is open has become a + // document of its own: it stays, and the preview gets a tab of its own. + let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none())); + if replace.is_none() && shown.is_some() { + self.untitled += 1; + } + nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); + let st = DocState::new(new_document(&nd), None); + let uid = st.uid; + match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { + Some((i, d)) => { + *d = st; + self.active = Some(i); + } + None => { + // Pushed without `add_document`: the document that was active stays as it is. + self.docs.push(st); + self.active = Some(self.docs.len() - 1); + } + } + self.new_document_preview = Some((uid, previous)); + Ok(uid) + } + + /// The uid of New Document's preview document, while one is shown. + pub fn new_document_preview(&self) -> Option { + self.new_document_preview.map(|(uid, _)| uid) + } + + /// End New Document's preview. Its document goes, and if it was still the active document, + /// the one that was active before it is active again. A preview that a menu or script has + /// edited or saved stays open as a document, with an Untitled number. Returns the uid of the + /// document removed. Without a preview it does nothing. + pub fn end_new_document_preview(&mut self) -> Option { + let (uid, previous) = self.new_document_preview.take()?; + let i = self.docs.iter().position(|d| d.uid == uid)?; + let d = self.docs.get(i)?; + if d.is_dirty() || d.path.is_some() { + self.untitled += 1; + return None; + } + self.docs.remove(i); + self.active = match self.active { + Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)), + Some(a) if a > i => Some(a - 1), + a => a, + }; + Some(uid) + } + + /// Create in the New Document window, with the settings the preview shows: the preview + /// document becomes the new document, as `file.new` would make it (the next Untitled number, + /// no undo history). Returns its index, or `None` when there is no unedited preview to keep. + pub fn keep_new_document_preview(&mut self) -> Option { + let (uid, _) = self.new_document_preview?; + let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?; + self.new_document_preview = None; + self.untitled += 1; + if let Some(d) = self.docs.get_mut(i) { + d.history.undo.clear(); + d.history.redo.clear(); + } + self.active = Some(i); + Some(i) + } +} + +pub(super) fn save_preset(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string(); + if name.chars().count() > MAX_PRESET_NAME { + return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters"))); + } + if NewDocument::from_preset(&name).is_some() { + return Err(bad("file.savePreset", format!("`{name}` is a built-in preset"))); + } + let mut nd = NewDocument::default(); + apply_settings(&mut nd, p); + check_size("file.savePreset", &nd)?; + nd.title = name.clone(); + let presets = &mut s.prefs.document_presets; + presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); + if presets.len() >= MAX_PRESETS { + return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets"))); + } + presets.push(nd); + Ok(json!({"name": name})) +} + +fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> { + if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) { + return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)")); + } + Ok(()) +} + +/// The document settings in `p` (as file.new takes them) over `nd`. +fn apply_settings(nd: &mut NewDocument, p: &Value) { + if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.intent = intent; + } + if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.units = units; + } + nd.width = f64_or(p, "width", nd.width); + nd.height = f64_or(p, "height", nd.height); + nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); + nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page); + nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); + nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); + nd.gutter = f64_or(p, "gutter", nd.gutter); + nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame); + match p.get("margins") { + Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)), + Some(v @ Value::Object(_)) => { + if let Ok(m) = serde_json::from_value(v.clone()) { + nd.margins = m; + } + } + _ => {} + } + if let Some(b) = edges(p, "bleed") { + nd.bleed = b; + } + if let Some(b) = edges(p, "slug") { + nd.slug = b; + } +} + +/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside]. +fn edges(p: &Value, key: &str) -> Option<[f64; 4]> { + let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 }; + match p.get(key)? { + Value::Array(a) => match a.iter().map(Value::as_f64).collect::>>()?.as_slice() { + [t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]), + _ => None, + }, + v => v.as_f64().map(|b| [clean(b); 4]), + } +} + +pub(super) fn file_sample(s: &mut Session, _p: &Value) -> Result { + let d = crate::sample::magazine(); + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i})) +} + +#[cfg(test)] +mod new_document_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn saved_presets_make_documents_by_name() { + let mut s = Session::new(); + let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277, + "pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50}, + "bleed": [9, 9, 0, 9], "primaryTextFrame": true}); + assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter"); + let listed = s.execute("file.presets", &json!({})).unwrap(); + let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect(); + assert_eq!(saved.len(), 1); + assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters"))); + // By name, case ignored; parameters still override the preset. + s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), 4); + assert!(!d.settings.facing_pages && d.settings.primary_text_frame); + assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters); + assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]); + assert_eq!(d.page(0).unwrap().margins.outside, 50.0); + assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's"); + // The same name replaces it. + s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap(); + assert_eq!(s.prefs.document_presets.len(), 1); + s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + // Built-in names, empty and overlong names and impossible sizes are refused. + for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})] + { + assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}"); + } + s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap(); + assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err()); + assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err()); + } + + #[test] + fn new_document_takes_intent_units_start_page_bleed_and_slug() { + let mut s = Session::new(); + let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18}); + s.execute("file.new", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.intent, designcraft_doc::Intent::Web); + assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels); + assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]); + assert_eq!(d.settings.slug, [18.0; 4]); + assert_eq!(d.page_name(0), "4"); + // Malformed edges are ignored, hostile ones bounded. + s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.bleed, [0.0; 4]); + assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); + } + + #[test] + fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() { + let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id())); + let mut s = Session::new(); + s.recovery_dir = Some(dir.clone()); + s.execute("file.new", &json!({})).unwrap(); + let first = s.doc().unwrap().uid; + let a = s.preview_new_document(&json!({"width": 300})).unwrap(); + assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2")); + let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap(); + assert_eq!(s.documents().len(), 2, "the preview replaces itself"); + assert!(s.preview_new_document(&json!({"width": 0})).is_err()); + let d = s.doc().unwrap(); + assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it"); + let listed = s.execute("document.list", &json!({})).unwrap(); + assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true))); + assert_eq!(s.end_new_document_preview(), Some(b)); + let d = s.doc().unwrap(); + assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0)); + assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing"); + // Edited by a script while shown, it writes no recovery data while it is a preview, and + // stays as a document when the window closes, with its Untitled number. + let c = s.preview_new_document(&json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0); + // The app's timer saves through the command. + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 0); + assert!(crate::recovery::list(&dir).is_empty()); + assert_eq!(s.end_new_document_preview(), None); + assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2")); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-3"); + // Create with the previewed settings keeps the preview as the document. + let d = s.preview_new_document(&json!({"pages": 2})).unwrap(); + assert_eq!(s.keep_new_document_preview(), Some(3)); + let st = s.doc().unwrap(); + assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None)); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-5"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod hyphenation_default_tests { + use std::sync::Arc; + + use designcraft_doc::BASIC_PARAGRAPH; + use designcraft_doc::build::NewDocument; + use serde_json::json; + + use crate::Session; + + fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { + let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); + (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) + } + + #[test] + fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); + + let mut d = designcraft_doc::Document::new(&NewDocument::default()); + let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); + st.para.hyph_capitalized = Some(true); + st.para.hyph_last_word = Some(true); + st.para.hyph_across_column = Some(true); + let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); + s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); + } +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs new file mode 100644 index 0000000000..d593267a5a --- /dev/null +++ b/crates/engine/src/cmd/file/open.rs @@ -0,0 +1,528 @@ +#[cfg(test)] +use super::bytes::{PREVIEW_PX, to_bytes}; +use super::bytes::{base64_decode, load_bytes}; +use super::*; + +pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> { + if s.prefs.recent_files.is_empty() { Err("no recent files".into()) } else { Ok(()) } +} + +/// A recent file's first-page picture as straight RGBA, for the start screen. +pub struct RecentPreview { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't +/// ours, or was saved before files had one (it gets one when saved again). +pub fn recent_preview(path: &str) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + // Only the zip directory and the preview entry are read, not the whole document. + let png = designcraft_format::read_preview(std::io::BufReader::new(crate::io::open(path).ok()?))?; + let img = designcraft_images::decode_rgba(&png)?; + let (width, height) = img.dimensions(); + Some(RecentPreview { width, height, rgba: img.into_raw() }) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + None + } +} + +/// One File › Open Recent row: the path, its file name and whether it is still there. +pub(super) fn recent_entry(path: &str) -> Value { + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); + #[cfg(not(target_arch = "wasm32"))] + let exists = crate::io::is_file(path); + #[cfg(target_arch = "wasm32")] + let exists = false; + json!({"path": path, "name": name, "exists": exists}) +} + +pub(super) fn file_open_recent(s: &mut Session, p: &Value) -> Result { + let index = + p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("file.openRecent", "missing `index`"))?; + let path = s + .prefs + .recent_files + .get(index) + .cloned() + .ok_or_else(|| bad("file.openRecent", format!("no recent file {index} (the list has {})", s.prefs.recent_files.len())))?; + #[cfg(not(target_arch = "wasm32"))] + if !crate::io::is_file(&path) { + return Err(EngineError::Other(format!("{path} can't be found: it was moved, renamed or deleted, or its disk isn't connected"))); + } + file_open(s, &json!({"path": path})) +} + +/// `file.open`: the document opens and moves to the front of File › Open Recent. +pub(super) fn file_open(s: &mut Session, p: &Value) -> Result { + let r = open_path(s, p)?; + if let Some(path) = str_param(p, "path") { + s.prefs.note_recent_file(path); + } + Ok(r) +} + +fn open_path(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; + #[cfg(not(target_arch = "wasm32"))] + { + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; + // The content decides first: a file's extension may be missing or wrong. The extension + // then routes files whose content isn't recognised to the reader that explains why. + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); + let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); + if designcraft_idml::is_idml(&bytes) || (!native && (crate::cmd::interchange::is_indd(path, &bytes) || ext == "idml")) { + return crate::cmd::interchange::open_idml(s, p); + } + if !native && ext != designcraft_format::EXTENSION { + return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file"))); + } + let (mut d, mut warnings) = load_bytes(&bytes)?; + crate::cmd::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path))); + crate::cmd::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); + let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path); + warnings.extend(font_warnings); + warnings.extend(crate::cmd::style::grep_style_warnings(&d)); + let mut st = DocState::new(d, Some(path.to_string())); + st.fonts = fonts; + let i = s.add_document(st); + Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (s, path); + Err(EngineError::Other("use file.openBytes on the web".into())) + } +} + +/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone +/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each +/// font file skipped). Nothing on the web (no folders). +pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option>, usize, Vec) { + #[cfg(not(target_arch = "wasm32"))] + { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; + // A document's folder is the agent's to name (a recovery entry or a book lists paths too): + // the fonts crate asks the automation roots about the folder and each font file, so fonts + // outside the read root stay unloaded. + let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p)); + d.font_scope = r.scope; + let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); + (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (d, path); + (None, 0, Vec::new()) + } +} + +/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it +/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins. +#[cfg(not(target_arch = "wasm32"))] +fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let exact = dir.join(DOCUMENT_FONTS_FOLDER); + if crate::io::is_dir(&exact) { + return exact; + } + crate::io::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) + .map_or(exact, |e| e.path()) +} + +pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result { + let b = base64_decode(str_param(p, "base64").unwrap_or("")); + // IDML packages are recognised by their stored `mimetype` first entry, INDD files by their + // signature (or their name). + if designcraft_idml::is_idml(&b) || crate::cmd::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) { + return crate::cmd::interchange::open_idml(s, p); + } + let (mut d, warnings) = load_bytes(&b)?; + crate::cmd::datamerge::resolve_sources_on_open(&mut d, None); + if let Some(n) = str_param(p, "name") { + d.title = n.to_string(); + } + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i, "warnings": warnings})) +} + +#[cfg(test)] +mod document_fonts_tests { + use std::path::{Path, PathBuf}; + + use designcraft_fonts::testing::font_with; + use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource}; + use serde_json::{Value, json}; + + use crate::Session; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap(); + dir + } + + /// A new document in `s` with a text frame holding `text` set in `family`. + fn new_document(s: &mut Session, family: &str, text: &str) { + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + } + + /// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML). + fn write_document(path: &Path, family: &str) { + write_document_with(path, family, "Hello"); + } + + fn write_document_with(path: &Path, family: &str, text: &str) { + let mut s = Session::new(); + new_document(&mut s, family, text); + if path.extension().is_some_and(|e| e == "idml") { + std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + } else { + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + } + } + + fn font_entry(s: &mut Session, family: &str) -> Value { + let l = s.execute("font.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) + } + + /// Each character of the active document's text with the face it is drawn in. + fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> { + let st = s.doc().unwrap(); + let mut out = Vec::new(); + for (sid, story) in &st.doc.stories { + let cs = s.cache.get(&st.doc, *sid, None); + for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) { + out.push((story.text[g.byte..].chars().next().unwrap(), g.face)); + } + } + out + } + + fn open(s: &mut Session, path: &Path) -> Value { + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap() + } + + #[test] + fn opening_a_document_loads_its_document_fonts() { + const FAMILY: &str = "DocFont Open Test"; + let dir = temp_dir("designcraft"); + let fonts = dir.join(DOCUMENT_FONTS_FOLDER); + std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap(); + let path = dir.join("Brochure.designcraft"); + write_document(&path, FAMILY); + assert!(!FontDb::global().has_family(FAMILY)); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}"); + let f = font_entry(&mut s, FAMILY); + assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn opening_an_idml_file_loads_its_document_fonts() { + const FAMILY: &str = "DocFont IDML Test"; + let dir = temp_dir("idml"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_indesign_file_says_how_to_bring_it_over() { + // The extension decides (any case), not the bytes: no InDesign-made file is needed. + let dir = temp_dir("indd"); + let path = dir.join("Brochure.INDD"); + std::fs::write(&path, b"not a DesignCraft document").unwrap(); + let mut s = Session::new(); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); + let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)"), "{e}"); + assert!(s.doc().is_err(), "nothing was opened"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_document_fonts_folder_is_found_whatever_its_case() { + const FAMILY: &str = "DocFont Case Test"; + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("Document fonts"); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = open(&mut s, &path); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_document_without_the_folder_opens_as_before() { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Plain.designcraft"); + write_document(&path, designcraft_fonts::DEFAULT_FAMILY); + let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] + fn document_fonts_belong_to_their_document() { + const FAMILY: &str = "DocFont Scoped"; + let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b")); + std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + write_document(&a.join("A.designcraft"), FAMILY); + write_document(&b.join("B.designcraft"), FAMILY); + + let mut s = Session::new(); + assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1); + assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0); + // B, open at the same time, doesn't have A's font. + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s)); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn each_document_sets_its_own_font_of_a_name() { + const FAMILY: &str = "DocFont Same"; + let (a, b) = (temp_dir("same-a"), temp_dir("same-b")); + let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf")); + // A's font has both letters, B's only `b`. + std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap(); + std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap(); + // The same words in both: the word cache must not hand one document's shaping to the other. + write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab"); + write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab"); + + let mut s = Session::new(); + open(&mut s, &a.join("A.designcraft")); + open(&mut s, &b.join("B.designcraft")); + // Fallback fonts for the `a` B's font lacks: never A's font. + s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); + let in_b = glyph_faces(&s); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + let in_a = glyph_faces(&s); + assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}"); + for (c, f) in &in_a { + assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A"); + } + for (c, f) in &in_b { + match c { + 'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"), + _ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"), + } + } + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() { + const FAMILY: &str = "DocFont Closed"; + let dir = temp_dir("closed"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Closed.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let first = glyph_faces(&s)[0].1; + assert_eq!(first.family, FAMILY); + s.execute("file.close", &json!({})).unwrap(); + // A new document can't use the closed document's font. + new_document(&mut s, FAMILY, "Hello"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY)); + // Reopened, the document has its font again: the face loaded the first time. + assert_eq!(open(&mut s, &path)["documentFonts"], 1); + assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_reverted_document_keeps_its_fonts() { + const FAMILY: &str = "DocFont Reverted"; + let dir = temp_dir("revert"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Revert.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn bold_and_italic_link_the_document_fonts_styles() { + const FAMILY: &str = "DocFont Linked"; + let dir = temp_dir("linked"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Linked.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + // The family is the document's alone: its Regular is the style without bold or italic. + let r = s.execute("type.bold", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let r = s.execute("type.italic", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod recent_tests { + use serde_json::json; + + use crate::Session; + + fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dc-recent-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn saved(s: &mut Session, path: &std::path::Path) -> String { + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + path.to_string_lossy().to_string() + } + + /// Opening and saving put a file at the front once; Save a Copy doesn't count. + #[test] + fn open_and_save_order_the_list_without_duplicates() { + let dir = temp_dir("order"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let b = saved(&mut s, &dir.join("b.designcraft")); + assert_eq!(s.prefs.recent_files, [b.clone(), a.clone()]); + s.execute("file.open", &json!({"path": a})).unwrap(); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "reopening moves it to the front, once"); + s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a copy isn't a recent file"); + let rows = s.execute("file.recentFiles", &json!({})).unwrap(); + assert_eq!(rows[0], json!({"path": a, "name": "a.designcraft", "exists": true})); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn open_recent_reopens_and_a_missing_file_is_an_error_that_keeps_it_listed() { + let dir = temp_dir("open"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let b = saved(&mut s, &dir.join("b.designcraft")); + let r = s.execute("file.openRecent", &json!({"index": 1})).unwrap(); + assert_eq!(s.documents()[r["index"].as_u64().unwrap() as usize].path.as_deref(), Some(a.as_str())); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()]); + std::fs::remove_file(&b).unwrap(); + let e = s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); + assert!(e.to_string().contains("can't be found"), "{e}"); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a disk that isn't connected comes back"); + assert_eq!(s.execute("file.recentFiles", &json!({})).unwrap()[1]["exists"], false); + // Hostile indices are errors, never panics. + for index in [json!(2), json!(u64::MAX), json!(-1), json!("0"), json!(null)] { + assert!(s.execute("file.openRecent", &json!({"index": index})).is_err(), "{index}"); + } + s.execute("file.clearRecent", &json!({})).unwrap(); + assert!(s.prefs.recent_files.is_empty()); + assert!(s.execute("file.openRecent", &json!({"index": 0})).is_err(), "disabled when empty"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Saving puts a small picture of the first page in the file, which the start screen reads + /// without opening the document. + #[test] + fn saved_files_carry_a_first_page_preview() { + let dir = temp_dir("preview"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let p = super::recent_preview(&a).expect("a preview"); + assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32); + assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize); + assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent"); + let copy = dir.join("copy.designcraft"); + s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too"); + // Files without one (saved before previews), missing files and other files have none. + let old = dir.join("old.designcraft"); + std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); + for path in [old, dir.join("gone.designcraft"), dir.clone()] { + assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_list_keeps_at_most_the_preferred_count() { + let mut s = Session::new(); + for i in 0..30 { + s.prefs.note_recent_file(&format!("/work/{i}.designcraft")); + } + assert_eq!(s.prefs.recent_files.len(), 20, "default: 20"); + assert_eq!(s.prefs.recent_files[0], "/work/29.designcraft"); + s.execute("prefs.set", &json!({"recentFilesMax": 3})).unwrap(); + assert_eq!(s.prefs.recent_files, ["/work/29.designcraft", "/work/28.designcraft", "/work/27.designcraft"]); + assert_eq!(s.execute("prefs.set", &json!({"recentFilesMax": 4_000_000_000u64})).unwrap()["recentFilesMax"], crate::MAX_RECENT_FILES); + assert!(s.execute("prefs.set", &json!({"recentFilesMax": -1})).is_err()); + s.execute("prefs.set", &json!({"recentFilesMax": 0})).unwrap(); + s.prefs.note_recent_file("/work/new.designcraft"); + assert!(s.prefs.recent_files.is_empty(), "0 keeps no list"); + } + + /// The list is a preference: it survives the round trip through prefs.json. + #[test] + fn the_list_survives_a_restart() { + let mut s = Session::new(); + s.prefs.note_recent_file("/work/a.designcraft"); + let saved = serde_json::to_vec(&s.prefs).unwrap(); + let back: crate::Prefs = serde_json::from_slice(&saved).unwrap(); + assert_eq!(back.recent_files, ["/work/a.designcraft"]); + assert_eq!(back.recent_files_max, 20); + let old: crate::Prefs = serde_json::from_slice(br#"{"recoveryMinutes": 2.0}"#).unwrap(); + assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); + } +} diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs new file mode 100644 index 0000000000..d9ecb3ad08 --- /dev/null +++ b/crates/engine/src/cmd/file/place.rs @@ -0,0 +1,753 @@ +#[cfg(test)] +use super::bytes::base64_encode; +use super::bytes::{base64_decode, from_bytes}; +use super::*; + +pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { + // The file's size and time before it is read (a linked text file remembers them). + let mut before = None; + let (bytes, name, link) = if let Some(b) = str_param(p, "base64") { + (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) + } else if let Some(path) = str_param(p, "path") { + before = crate::cmd::datamerge::fingerprint_from_path(path); + #[cfg(not(target_arch = "wasm32"))] + let b = crate::io::read_file(path).map_err(EngineError::Other)?; + #[cfg(target_arch = "wasm32")] + let b: Vec = vec![]; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); + (b, name, Some(path.to_string())) + } else { + s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); + return ok(); + }; + // A page of another layout (IDML or DesignCraft): its objects, as one group. + let lname = name.to_lowercase(); + if lname.ends_with(".idml") || lname.ends_with(".designcraft") { + return place_layout_page(s, p, &name, &bytes); + } + // Keep the EPS source for link comparison; rendering uses its preview at the bounding box size. + let eps_size = eps_size(&bytes, "file.place")?; + // Video and sound: a media frame (Window › Interactive › Media). + if let Some(mime) = designcraft_doc::media_mime(&name) { + return crate::cmd::media::place_media(s, p, name, mime, bytes, link); + } + // Text files (plain, Word, RTF) flow into frames. + if designcraft_textimport::is_text_file(&name) { + return crate::cmd::place_text::place_text(s, p, &name, &bytes, link, before); + } + let want_w = p + .get("width") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_f64() + .filter(|width| width.is_finite() && *width > 0.0) + .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) + }) + .transpose()?; + // Image Import Options: which page of a PDF (1-based). + if designcraft_render::is_pdf(&bytes) { + designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?; + } + let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { + Some(n) if designcraft_render::is_pdf(&bytes) => { + let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); + let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); + let one_based = usize::try_from(n).map_err(|_| invalid_page())?; + if one_based == 0 || one_based > count { + return Err(invalid_page()); + } + let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; + u32::try_from(zero_based).map_err(|_| invalid_page())? + } + _ => 0, + }; + let (pw, ph) = match pdf_page { + 0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?, + n => designcraft_render::pdf_page_size(&bytes, n as usize) + .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) + .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, + }; + // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages + // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. + let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); + // Image Import Options › Crop to: the graphic spans that box of the PDF page. + let is_pdf = designcraft_render::is_pdf(&bytes); + let pdf_crop = match str_param(p, "pdfCrop") { + Some(k) if is_pdf => PdfCrop::from_name(k) + .ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?, + _ => PdfCrop::Crop, + }; + let mime = designcraft_render::image_mime(&bytes).to_string(); + let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; + // The graphic's natural size: the box in points (the whole page when the box can't be found, + // and then the document says so). + let (bw, bh) = match crop_pdf(&mut asset) { + Some(size) => size, + None => { + asset.pdf_crop = PdfCrop::Crop; + (nw, nh) + } + }; + let target_frame = crate::cmd::id_param(p, "frame").or_else(|| { + if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + return None; + } + let st = s.active()?; + st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) + }); + // `"spread": null` counts as omitted, like the other placement parameters. + let spread = match p.get("spread") { + Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0), + _ => crate::cmd::spread_param(p, "file.place")?, + }; + let lid = s.doc()?.active_layer; + let px = p.get("x").and_then(Value::as_f64); + let py = p.get("y").and_then(Value::as_f64); + if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { + return load_cursor(s, asset, (bw, bh)); + } + s.edit(|d, sel| { + let fresh = AssetId(d.alloc()); + let aid = d.add_asset(Asset { id: fresh, ..asset }); + // The width the whole page (or image) gets; the height follows the proportions. + let w = match want_w { + Some(w) => w, + None => { + let page_w = d.settings.page_width * 0.6; + nw.min(page_w) + } + }; + let id = if let Some(fid) = target_frame { + let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; + let r = it.inner_bounds(); + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + let k = (r.width() / bw).max(r.height() / bh); + let (gw, gh) = (bw * k, bh * k); + let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); + validate_placement((bw, bh), r, xf, "file.place")?; + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (bw, bh), + xf, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + fid + } else { + let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?; + let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0)); + let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)); + let id = ItemId(d.alloc()); + let k = w / nw; + let r = crate::cmd::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?; + let xf = Affine::translate((x, y)) * Affine::scale(k); + validate_placement((bw, bh), r, xf, "file.place")?; + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (bw, bh), + xf, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(spread, it, None)?; + id + }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0, "asset": aid.0})) + }) +} + +/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in +/// points. The crop box is the whole page and has none; nor has a page that can't be read, which +/// keeps its crop choice (a missing link relinked later gets its box then). +pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { + a.pdf_box = None; + if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) { + return None; + } + let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?; + a.pdf_box = Some(frac); + Some(size) +} + +pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { + if let Some(b) = str_param(p, "base64") { + return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); + } + let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; + #[cfg(not(target_arch = "wasm32"))] + let b = crate::io::read_file(path).map_err(EngineError::Other)?; + #[cfg(target_arch = "wasm32")] + let b: Vec = vec![]; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); + Ok((b, name, Some(path.to_string()))) +} + +fn eps_size(bytes: &[u8], command: &str) -> Result> { + if !designcraft_images::is_eps(bytes) { + return Ok(None); + } + designcraft_images::eps_size(bytes) + .map(Some) + .ok_or_else(|| bad(command, "the EPS has a missing or invalid bounding box (dimensions must be 0.001–20,000 points)")) +} + +/// Fitting can overflow even with finite inputs. Never persist NaN/Inf as JSON nulls. +pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, command: &str) -> Result<()> { + let valid_rect = |r: Rect| [r.x0, r.y0, r.x1, r.y1, r.width(), r.height()].iter().all(|n| n.is_finite()) && r.width() >= 0.0 && r.height() >= 0.0; + if !size.0.is_finite() + || !size.1.is_finite() + || size.0 <= 0.0 + || size.1 <= 0.0 + || !valid_rect(frame) + || !xf.as_coeffs().iter().all(|n| n.is_finite()) + || !valid_rect(xf.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1))) + { + return Err(bad(command, "image placement exceeds finite geometry limits")); + } + Ok(()) +} + +pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { + let (bytes, name, link) = read_source(p)?; + let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; + let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; + load_cursor(s, asset, natural) +} + +/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. +fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result { + let name = asset.name.clone(); + let (pw, ph) = asset.pixels.unwrap_or_default(); + let aid = s.edit(|d, _| { + asset.id = AssetId(d.alloc()); + Ok(d.add_asset(asset)) + })?; + s.loaded = Some(crate::LoadedGraphic { asset: aid, size }); + s.set_tool("placeGun"); + Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) +} + +pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { + let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; + // Undo can remove the loaded graphic's asset. + if !s.doc()?.doc.assets.contains_key(&aid) { + s.loaded = None; + return Err(bad("place.drop", "the loaded graphic is no longer in the document")); + } + let sr = crate::cmd::spread_param(p, "place.drop")?; + let lid = s.doc()?.active_layer; + let frame = crate::cmd::id_param(p, "frame"); + let rect = crate::cmd::rect_param(p, "rect"); + let (x, y) = (crate::cmd::f64_or(p, "x", 0.0), crate::cmd::f64_or(p, "y", 0.0)); + let d = &s.doc()?.doc; + let (frame_rect, xf, auto_fit) = if let Some(fid) = frame { + let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); + let k = (r.width() / nw).max(r.height() / nh); + let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + (r, xf, Default::default()) + } else { + if d.spread(sr).is_none() { + return Err(bad("place.drop", "no such spread")); + } + // Drag: fit proportionally; click: actual size at the point. + let (r, k) = match rect { + Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + let k = (r.width() / nw).min(r.height() / nh); + (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) + } + _ => (Rect::new(x, y, x + nw, y + nh), 1.0), + }; + (r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default()) + }; + validate_placement((nw, nh), frame_rect, xf, "place.drop")?; + let r = s.edit(|d, sel| { + let graphic = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (nw, nh), + xf, + clip: None, + auto_fit, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let id = if let Some(fid) = frame { + d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic; + fid + } else { + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect)); + it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } + it.content = graphic; + d.insert_item(sr, it, None)?; + id + }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0})) + })?; + s.loaded = None; + s.set_tool("selection"); + Ok(r) +} + +/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, +/// 1-based) grouped, top-left at x/y (default: the source position on the first page). +fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { + const ID: &str = "file.place"; + let src = if name.to_lowercase().ends_with(".idml") { + crate::cmd::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))? + } else { + from_bytes(bytes)? + }; + let n = src.page_count(); + let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize; + if page == 0 || page > n { + return Err(bad(ID, format!("the document has {n} page(s)"))); + } + let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?; + let sp = &src.spreads[si]; + let pr = sp.pages[pi].bounds(); + let ids: Vec = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect(); + if ids.is_empty() { + return Err(bad(ID, format!("page {page} has no objects"))); + } + let to = crate::cmd::spread_param(p, ID)?; + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), + _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), + }; + let placed = s.edit(|d, sel| { + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = crate::cmd::object::duplicate_from(d, &src, &ids, to, off)?; + *sel = Selection::items(new.clone()); + Ok(new) + })?; + if placed.len() > 1 { + let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::>()}))?; + return Ok(json!({"id": g["id"], "items": placed.len()})); + } + Ok(json!({"id": placed[0].0, "items": 1})) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(super) mod image_place_tests { + use designcraft_doc::{Content, ItemId}; + use designcraft_geom::Rect; + use serde_json::{Value, json}; + + use crate::Session; + + pub(in crate::cmd::file) fn image_file() -> std::io::Result { + image_file_with_color(30) + } + + fn image_file_with_color(red: u8) -> std::io::Result { + let file = tempfile::Builder::new().suffix(".png").tempfile()?; + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); + std::fs::write(file.path(), png)?; + Ok(file) + } + + #[test] + fn consecutive_path_placements_create_distinct_graphics() { + let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let mut placements = Vec::new(); + let mut originals = Vec::new(); + + for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { + let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + assert!( + placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), + "each explicit placement needs a distinct frame: {placed}" + ); + let document = &session.doc().unwrap().doc; + let item = document.item(id).unwrap(); + assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); + assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); + originals.push(serde_json::to_value(item).unwrap()); + placements.push(placed); + } + + let document = &session.doc().unwrap().doc; + assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); + assert_eq!(document.assets.len(), 3); + for (placed, original) in placements.iter().zip(originals) { + assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); + } + let links = session.execute("links.list", &json!({})).unwrap(); + assert_eq!(links.as_array().unwrap().len(), 3); + for (placed, image) in placements.into_iter().zip(&images) { + let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); + assert_eq!(row["path"], json!(image.path())); + assert_eq!(row["uses"].as_array().unwrap().len(), 1); + assert_eq!(row["uses"][0]["id"], placed["id"]); + } + } + + #[test] + fn individual_placement_parameters_bypass_selected_frames() { + let image = image_file().unwrap(); + for graphic in [false, true] { + for (key, value, expected) in [ + ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), + ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), + ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), + ("width", 0, Rect::ZERO), + ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), + ] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + if graphic { + session.execute("file.place", &json!({"path": image.path()})).unwrap(); + } + let id = ItemId(selected["id"].as_u64().unwrap()); + let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); + let mut params = json!({"path": image.path()}); + params[key] = json!(value); + + let result = session.execute("file.place", ¶ms); + if key == "width" && value == 0 { + assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); + continue; + } + let placed = result.unwrap(); + + assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); + let document = &session.doc().unwrap().doc; + assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), expected); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); + assert!(scale_x > 0.0 && scale_y > 0.0); + } + } + } + + #[test] + fn explicit_frame_wins_over_placement_and_selection() { + let image = image_file().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); + + let placed = + session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); + + assert_eq!(placed["id"], target["id"]); + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(item.content, Content::Graphic(_))); + assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); + assert_eq!(document.spreads[0].items.len(), 2); + } + + fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { + let image = image_file()?; + for graphic in [false, true] { + for explicit_frame in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + if graphic { + session.execute("file.place", &json!({"path": image.path()}))?; + } + let mut params = json!({"path": image.path(), "width": width}); + if explicit_frame { + params["frame"] = target["id"].clone(); + session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; + } + let before = session.doc()?; + let document = before.doc.clone(); + let selection = before.selection.clone(); + let revision = before.revision; + let assets = document.assets.len(); + + let result = session.execute("file.place", ¶ms); + + assert!( + matches!(&result, Err(crate::EngineError::BadParams { .. })), + "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" + ); + let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; + let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; + assert_eq!(cmd, "file.place"); + assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); + let after = session.doc()?; + assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); + assert_eq!(after.selection, selection); + assert_eq!(after.revision, revision); + assert_eq!(after.doc.assets.len(), assets); + } + } + Ok(()) + } + + #[test] + fn width_rejected_when_zero() { + assert_width_rejected(json!(0)).unwrap(); + } + + #[test] + fn width_rejected_when_negative() { + assert_width_rejected(json!(-80)).unwrap(); + } + + #[test] + fn width_rejected_when_non_numeric() { + for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { + assert_width_rejected(width).unwrap(); + } + } + + fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { + let image = image_file()?; + let previous_image = image_file_with_color(31)?; + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; + let mut params = json!({"path": image.path()}); + for key in keys { + params[*key] = Value::Null; + } + + let placed = session.execute("file.place", ¶ms)?; + + assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc()?.doc; + let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); + let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); + let Content::Graphic(content) = &item.content else { + return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); + }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + Ok(()) + } + + #[test] + fn selection_used_when_x_null() { + assert_null_placement_uses_selection(&["x"]).unwrap(); + } + + #[test] + fn selection_used_when_y_null() { + assert_null_placement_uses_selection(&["y"]).unwrap(); + } + + #[test] + fn selection_used_when_spread_null() { + assert_null_placement_uses_selection(&["spread"]).unwrap(); + } + + #[test] + fn selection_used_when_width_null() { + assert_null_placement_uses_selection(&["width"]).unwrap(); + } + + #[test] + fn selection_used_when_all_placement_parameters_null() { + assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); + } + + #[test] + fn implicit_placement_fills_empty_and_replaces_graphic_selection() { + let image = image_file().unwrap(); + let previous_image = image_file_with_color(31).unwrap(); + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); + + let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); + + assert_eq!(placed["id"], selected["id"]); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + } +} + +#[cfg(test)] +mod pdf_crop_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_pdf_cropped_to_its_trim_box() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let size = |t: &mut Session, crop: &str| { + t.execute("edit.deselectAll", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": crop, "width": 630, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + t.doc().unwrap().doc.item(id).unwrap().bounds() + }; + let full = size(&mut t, "crop"); + let trim = size(&mut t, "trim"); + assert!((full.width() - 630.0).abs() < 1e-6, "{full:?}"); + assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); + assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); + } + + /// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it, + /// even in a frame larger than the graphic. + #[test] + fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() { + // Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + } + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + // `width` is the whole page's (630 pt with its bleed): half size. + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = t.doc().unwrap().doc.clone(); + let it = d.item(id).unwrap(); + let g = it.graphic().unwrap().clone(); + let a = &d.assets[&g.asset]; + assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim); + let b = a.shown_box().unwrap(); + let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0]; + assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}"); + assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size); + let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + let fb = it.bounds(); + for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] { + assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}"); + } + + // A frame larger than the graphic still shows only the trim box. + t.edit(|d, _| { + d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0)); + Ok(()) + }) + .unwrap(); + let d = t.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap(); + let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60; + // The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows + // where it is on the page. + assert!(!dark(97, 97), "the bleed is outside the trim box"); + assert!(!dark(103, 103), "the page is offset by its bleed"); + assert!(dark(110, 110), "the square inside the trim box"); + } + + #[test] + fn placed_pdf_keeps_its_exact_page_size() { + // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = &t.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let b = it.bounds(); + assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; + assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); + } +} + +#[cfg(test)] +mod layout_place_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_a_page_of_an_idml_file_as_a_group() { + let mut a = Session::new(); + a.execute("file.new", &json!({"pages": 2})).unwrap(); + a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap(); + a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap(); + let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc); + let b64 = super::base64_encode(&idml); + let mut b = Session::new(); + b.execute("file.new", &json!({})).unwrap(); + let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); + assert_eq!(r["items"], 2); + let d = &b.doc().unwrap().doc; + let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap(); + assert_eq!(g.children().len(), 2, "one group"); + let bb = g.bounds(); + assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}"); + assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along"); + assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); + } +} diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs new file mode 100644 index 0000000000..3d2e58093c --- /dev/null +++ b/crates/engine/src/cmd/file/registry.rs @@ -0,0 +1,139 @@ +use super::bytes::{base64_encode, to_bytes, to_bytes_with_preview, with_document_extension}; +use super::new::{file_new, file_sample, save_preset}; +use super::open::{file_open, file_open_bytes, file_open_recent, has_recent, recent_entry}; +use super::place::{file_place, place_drop, place_load, read_source}; +use super::save::{FolderAccess, can_revert, file_revert, file_save, recovery_dir}; +use super::snippets::{snippet_export, snippet_place}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"), + "{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?, primaryTextFrame?: bool}", + always, file_new), + cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), + cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| { + let mut presets: Vec = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect(); + presets.extend( + s.prefs + .document_presets + .iter() + .map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})), + ); + Ok(Value::Array(presets)) + }), + cmd!(noundo "file.savePreset", "Save Document Preset", [], None, + "{name, …the settings as file.new} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused", + always, save_preset), + cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| { + let name = str_param(p, "name").unwrap_or_default().trim().to_string(); + let before = s.prefs.document_presets.len(); + s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); + if s.prefs.document_presets.len() == before { + return Err(bad("file.deletePreset", format!("no saved preset `{name}`"))); + } + ok() + }), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), + "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", + always, file_open), + cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), + cmd!(noundo "file.openRecent", "Open Recent File", [], None, + "{index} — reopen entry `index` of File › Open Recent (as file.open); a file that is gone is an error and stays listed", + has_recent, file_open_recent), + cmd!(query "file.recentFiles", "Recent Files", [], None, "{} → [{path, name, exists}] File › Open Recent, most recent first", always, |s, _| { + Ok(Value::Array(s.prefs.recent_files.iter().map(|p| recent_entry(p)).collect())) + }), + cmd!(noundo "file.clearRecent", "Clear Recent Files", ["File", "Open Recent"], None, "{} — empty File › Open Recent", has_recent, |s, _| { + s.prefs.recent_files.clear(); + ok() + }), + cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?; + let path = with_document_extension(path); + let mut copy = (*s.doc()?.doc).clone(); + crate::cmd::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); + let bytes = to_bytes_with_preview(©, &s.cache)?; + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": bytes.len()})) + }), + cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), + cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { + let dir = recovery_dir(s, p, FolderAccess::Read)?; + Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) + }), + cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { + let dir = recovery_dir(s, p, FolderAccess::Write)?; + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) + }), + cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { + // Reopening removes the entries it read. + let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; + Ok(json!({"opened": crate::recovery::open(s, &dir)?})) + }), + cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { + let st = s.doc()?; + // Preview fill is session state. Serialize the unfilled template. + let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref()); + let bytes = to_bytes(doc)?; + Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})) + }), + cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| { + let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0); + s.close_document(i); + ok() + }), + cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| { + s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); + ok() + }), + cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, + "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", crate::cmd::has_selection, snippet_export), + cmd!(query "place.styles", "Import Options: Styles", [], None, + "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", + has_doc, |s, p| { + let (bytes, name, _) = read_source(p)?; + let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; + let st = &s.doc()?.doc.styles; + let conflicts: Vec<&str> = imp + .para_styles + .iter() + .filter(|x| st.para(&x.name).is_some()) + .chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some())) + .map(|x| x.name.as_str()) + .collect(); + Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::>(), "conflicts": conflicts})) + }), + cmd!( + "snippet.place", + "Place Snippet", + [], + None, + "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", + has_doc, + snippet_place + ), + cmd!( + "place.load", + "Load Place Cursor", + [], + None, + "{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)", + has_doc, + place_load + ), + cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop), + cmd!( + "file.place", + "Place…", + ["File"], + Some("Cmd+D"), + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", + has_doc, + file_place + ), + ] +} diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs new file mode 100644 index 0000000000..38b3f8d464 --- /dev/null +++ b/crates/engine/src/cmd/file/save.rs @@ -0,0 +1,210 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::bytes::from_bytes; +use super::bytes::{to_bytes_with_preview, with_document_extension}; +#[cfg(not(target_arch = "wasm32"))] +use super::open::load_document_fonts; +#[cfg(test)] +use super::place::image_place_tests; +use super::*; + +pub(super) fn file_save(s: &mut Session, p: &Value) -> Result { + let st = s.doc_mut()?; + // Saving to the document's own file keeps its name, even one opened without the extension. + let path = str_param(p, "path") + .map(with_document_extension) + .or_else(|| st.path.clone()) + .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + // Before anything about the document changes: a refused save leaves it as it was. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; + // Prepare paths for the destination without changing the live document until persistence succeeds. + let mut saved = (*st.doc).clone(); + crate::cmd::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); + let paths_changed = saved.data_merge != st.doc.data_merge; + let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; + let bytes = to_bytes_with_preview(&saved, &s.cache)?; + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; + let st = s.doc_mut()?; + if paths_changed { + st.doc = saved; + st.bump_revision(); + } + st.path = Some(path.clone()); + st.saved_revision = st.revision; + st.saved_doc = st.doc.clone(); + #[cfg(not(target_arch = "wasm32"))] + { + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + s.prefs.note_recent_file(&path); + } + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +/// Which access a recovery command needs to the folder it is given. +#[derive(Clone, Copy)] +pub(super) enum FolderAccess { + Read, + Write, + ReadWrite, +} + +/// The recovery folder: `dir` when the command names one, else the session's own (set by the +/// host application, so not the automation roots' business). A named folder is checked against +/// the roots for `access`. +pub(super) fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p, access); + Err(EngineError::Other("no recovery folder on the web".into())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let Some(named) = str_param(p, "dir") else { + return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); + }; + if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { + crate::file_access::check_read(named).map_err(EngineError::Other)?; + } + if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { + crate::file_access::check_write(named).map_err(EngineError::Other)?; + } + Ok(std::path::PathBuf::from(named)) + } +} + +pub(super) fn can_revert(s: &Session) -> std::result::Result<(), String> { + match s.active() { + Some(d) if d.path.is_some() && d.is_dirty() => Ok(()), + Some(d) if d.path.is_none() => Err("the document has never been saved".into()), + Some(_) => Err("no changes since the last save".into()), + None => Err("no document open".into()), + } +} + +pub(super) fn file_revert(s: &mut Session, _: &Value) -> Result { + let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; + #[cfg(not(target_arch = "wasm32"))] + { + let mut d = if path.to_ascii_lowercase().ends_with(".idml") { + let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; + crate::cmd::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))? + } else { + from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? + }; + // As opened again: the fonts beside it now (unchanged files are shared, not read again). + let (fonts, _, _) = load_document_fonts(&mut d, &path); + let uid = s.doc()?.uid; + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + let mut fresh = DocState::new(d, Some(path.clone())); + fresh.fonts = fonts; + fresh.uid = uid; + // A new state has a revision no state had before, so caches don't show the old document. + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + *s.doc_mut()? = fresh; + Ok(json!({"path": path})) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (s, path); + Err(EngineError::Other("revert isn't available on the web".into())) + } +} + +#[cfg(test)] +mod save_tests { + use serde_json::json; + + use crate::Session; + + /// Saving over an existing document replaces it whole and leaves nothing beside it. + #[test] + fn save_replaces_the_existing_file_without_leftovers() { + let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(saved.stories.values().any(|st| st.text == "Saved whole")); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); + // A folder that doesn't exist: an error naming the path. + let missing = dir.join("missing").join("doc.designcraft"); + let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A document that can't be written is an error: the file on disk, the unsaved state and the + /// recovery checkpoint all stay as they were. + #[test] + fn a_save_that_cannot_write_the_document_changes_nothing() { + let dir = std::env::temp_dir().join(format!("dc-save-fails-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + let recovery = dir.join("recovery"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let before = std::fs::read(&path).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Unsaved", "caret": false})).unwrap(); + s.execute("file.recovery.save", &json!({"dir": recovery.to_string_lossy()})).unwrap(); + let checkpoint = recovery.join(format!("{}.designcraft", s.doc().unwrap().uid)); + assert!(checkpoint.exists()); + + super::FAIL_TO_BYTES.with(|f| f.set(true)); + let save = s.execute("file.save", &json!({})); + let copy = s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})); + let save_as = s.execute("file.saveAs", &json!({"path": dir.join("other.designcraft").to_string_lossy()})); + let serialized = s.execute("file.serialize", &json!({})); + super::FAIL_TO_BYTES.with(|f| f.set(false)); + + let e = save.unwrap_err(); + assert!(e.to_string().contains("could not write the document"), "{e}"); + assert!(copy.is_err() && serialized.is_err(), "no empty copy and no empty download"); + assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); + assert!(!dir.join("copy.designcraft").exists()); + assert!(save_as.is_err() && !dir.join("other.designcraft").exists()); + assert!(!s.prefs.recent_files.iter().any(|p| p.ends_with("other.designcraft")), "a failed Save As isn't a recent file"); + assert!(s.doc().unwrap().is_dirty(), "the document still has unsaved changes"); + assert!(checkpoint.exists(), "the recovery checkpoint stays"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1050: a placement whose coordinates overflow to infinity is refused, and Save refuses a + /// document holding a non-finite rect anyway, leaving the file on disk as it was. + #[test] + fn non_finite_coordinates_are_refused_and_never_saved() { + let image = super::image_place_tests::image_file().unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let e = s.execute("file.place", &json!({"path": image.path(), "x": 1e308, "width": 1e308})).unwrap_err(); + assert!(e.to_string().contains("finite"), "{e}"); + assert!(s.doc().unwrap().doc.all_items().is_empty(), "nothing is placed"); + + let dir = std::env::temp_dir().join(format!("dc-save-non-finite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let before = std::fs::read(&path).unwrap(); + let id = designcraft_doc::ItemId(s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap()["id"].as_u64().unwrap()); + let st = s.doc_mut().unwrap(); + let infinite = designcraft_geom::Rect::new(36.0, 36.0, f64::INFINITY, 200.0); + std::sync::Arc::make_mut(&mut st.doc).item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(infinite); + let e = s.execute("file.save", &json!({})).unwrap_err(); + assert!(e.to_string().contains("not a finite number"), "{e}"); + assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); + assert!(s.doc().unwrap().is_dirty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/file/snippets.rs b/crates/engine/src/cmd/file/snippets.rs new file mode 100644 index 0000000000..231e2196ba --- /dev/null +++ b/crates/engine/src/cmd/file/snippets.rs @@ -0,0 +1,54 @@ +use super::bytes::{base64_encode, from_bytes, to_bytes}; +use super::place::read_source; +use super::*; + +/// The selection as `.designcraft` snippet bytes. +pub(crate) fn snippet_bytes(s: &mut Session) -> Result> { + to_bytes(&crate::cmd::edit::clip_doc(s)?) +} + +pub(super) fn snippet_export(s: &mut Session, p: &Value) -> Result { + let d = crate::cmd::edit::clip_doc(s)?; + let bytes = to_bytes(&d)?; + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) + } + None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), + } +} + +pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { + let (bytes, _, _) = read_source(p)?; + let src = from_bytes(&bytes)?; + let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); + if ids.is_empty() { + return Err(bad("snippet.place", "the snippet is empty")); + } + let sr = crate::cmd::spread_param(p, "snippet.place")?; + let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), + _ => designcraft_geom::Vec2::ZERO, + }; + s.edit(|d, sel| { + // Bring over styles and swatches the snippet uses that this document lacks. + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = crate::cmd::object::duplicate_from(d, &src, &ids, sr, off)?; + *sel = Selection::items(new.clone()); + Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) + }) +} diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 69e0169a98..8d7c686325 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -441,763 +441,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn find_and_change_text_and_grep() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Call 555-1234 or 555-9876.\nColour and colour."})) - .unwrap(); - let m = s.execute("find.find", &json!({"find": "colour"})).unwrap(); - assert_eq!(m.as_array().unwrap().len(), 2); - let m = s.execute("find.find", &json!({"find": "colour", "caseSensitive": true})).unwrap(); - assert_eq!(m.as_array().unwrap().len(), 1); - let r = s.execute("find.change", &json!({"find": r"(\d{3})-(\d{4})", "change": "($1) $2", "grep": true})).unwrap(); - assert_eq!(r["count"], 2); - let r = s.execute("find.change", &json!({"find": "colour", "change": "color", "attrs": {"fontStyle": "Italic"}})).unwrap(); - assert_eq!(r["count"], 2); - let d = s.execute("document.inspect", &json!({})).unwrap(); - let sid = d["stories"][0]["id"].as_u64().unwrap(); - let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); - assert_eq!(text, "Call (555) 1234 or (555) 9876.\ncolor and color."); - // Paragraph metacharacter in text mode. - assert_eq!(s.execute("find.find", &json!({"find": ".^pc"})).unwrap().as_array().unwrap().len(), 1); - s.execute("find.next", &json!({"find": "or"})).unwrap(); - assert!(s.doc().unwrap().selection.text.is_some()); - } - - #[test] - fn change_first_changes_one_match_in_the_whole_document() { - for grep in [false, true] { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for y in [36, 336] { - s.execute("frame.create", &json!({"rect": [36, y, 300, y + 200], "content": "text", "text": "red and red"})).unwrap(); - } - let r = s.execute("find.change", &json!({"find": "red", "change": "blue", "first": true, "grep": grep})).unwrap(); - assert_eq!(r["count"], 1); - assert_eq!(s.execute("find.find", &json!({"find": "blue"})).unwrap().as_array().unwrap().len(), 1); - assert_eq!(s.execute("find.find", &json!({"find": "red"})).unwrap().as_array().unwrap().len(), 3); - } - } - - fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); - (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) - } - - fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { - s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); - } - - fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { - assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); - let selected = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); - } - - #[test] - fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { - for (texts, first, explicit, grep, expected, count) in [ - (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), - (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), - (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), - (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), - (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), - (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), - (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), - (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), - (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), - ] { - let (mut s, sid) = scope_session(texts[0]); - let mut sids = vec![sid]; - for text in texts.iter().skip(1) { - let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); - sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); - } - assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::>(), sids); - // Document Change keeps canonical target order even with a later story current. - scope_select(&mut s, *sids.last().unwrap(), 0, 0); - let before = s.doc().unwrap().doc.clone(); - let mut p = json!({ - "find": if grep { "(?:)" } else { "red" }, - "change": if grep { "X" } else { "blue" }, - "scope": "document", "grep": grep - }); - if let Some(first) = first { - p["first"] = json!(first); - } - if explicit { - p["story"] = json!(sid.0); - p["scope"] = json!("selection"); - } - if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { - let last = *sids.last().unwrap(); - scope_hit(&mut s, &p, last, 0, 3); - let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; - assert_eq!( - s.execute("find.find", &p).unwrap(), - json!([ - {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, - {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, - {"story": last.0, "start": 0, "end": 3, "text": "red"} - ]), - "Count remains canonical after Next paints the later story" - ); - } - let selection = s.doc().unwrap().selection.clone(); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); - let st = s.doc().unwrap(); - assert_eq!(st.selection, selection); - for (i, sid) in sids.iter().enumerate() { - assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); - if texts[i] == expected[i] { - assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); - } - } - st.doc.check().unwrap(); - } - - // Identical replacement is still one counted match, not a reason to visit another story. - let (mut s, sid) = scope_session("red red"); - let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); - let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, other_sid, 0, 3); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - assert_eq!( - s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), - json!({"count": 1}) - ); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); - assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); - assert_eq!(s.doc().unwrap().selection, selection); - s.doc().unwrap().doc.check().unwrap(); - - for reverse in [false, true] { - let (mut s, sid) = scope_session("éX X"); - let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); - let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); - let before = s.doc().unwrap().doc.clone(); - let mut selection = s.doc().unwrap().selection.clone(); - let t = selection.text.as_mut().unwrap(); - (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; - let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); - assert_eq!(st.doc.story(other_sid), before.story(other_sid)); - assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); - st.doc.check().unwrap(); - assert_eq!( - s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), - json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) - ); - } - } - - #[test] - fn grep_in_the_documents_dialect() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Москва and Paris, 1\u{2013}2 days.\nok"})).unwrap(); - // `\u` uppercase, `\l` lowercase letter. - let m = s.execute("find.find", &json!({"find": r"\u\l+", "grep": true, "caseSensitive": true})).unwrap(); - let words: Vec<&str> = m.as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect(); - assert_eq!(words, ["Москва", "Paris"]); - // `~=` en dash, `\h` space, `\r` paragraph end; the change uses groups, `~S` and `\r`. - let r = s.execute("find.change", &json!({"find": r"(\d)~=(\d)\h(\l+)\.\r", "change": r"$1~=$2~S$3!\r", "grep": true})).unwrap(); - assert_eq!(r["count"], 1); - let sid = s.execute("document.inspect", &json!({})).unwrap()["stories"][0]["id"].as_u64().unwrap(); - let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(text.starts_with("Москва and Paris, 1\u{2013}2\u{A0}days!\nok"), "{text:?}"); - // A pattern that doesn't compile is an error, not a silent no-op. - assert!(s.execute("find.find", &json!({"find": "[[:nope:]]", "grep": true})).is_err()); - assert!(s.execute("find.change", &json!({"find": "(", "change": "", "grep": true})).is_err()); - } - - #[test] - fn find_scope_exact_baseline_count_next_change() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let frame = s - .execute( - "frame.create", - &json!({ - "rect": [36, 36, 300, 200], "content": "text", "text": "red red" - }), - ) - .unwrap(); - let sid = frame["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - assert_eq!( - s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), - json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) - ); - assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); - assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); - assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); - } - - #[test] - fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { - for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { - for reverse in [false, true] { - let (mut s, sid) = scope_session(text); - // Real caret movement keeps the frame identity, unlike text.select. - s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); - if !reverse { - s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); - } - let selected = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); - assert!(selected.frame.is_some()); - let mut expected = s.doc().unwrap().selection.clone(); - let t = expected.text.as_mut().unwrap(); - (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; - let p = json!({"find": find, "change": change, "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().text, changed); - assert_eq!(st.selection, expected); - let t = st.selection.text.unwrap(); - assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); - assert!(s.find_context.is_none()); - let p = json!({"find": change, "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); - scope_hit(&mut s, &p, sid, start, new_end); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - } - } - } - - #[test] - fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { - for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ - ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. - ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. - ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. - ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), - ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. - ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), - ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. - ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. - ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. - ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), - ("abc", (1, 3), "abc", "", "", (0, 0), 1), - ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. - ] { - let (mut s, sid) = scope_session(text); - scope_select(&mut s, sid, a, b); - let mut expected = s.doc().unwrap().selection.clone(); - let t = expected.text.as_mut().unwrap(); - (t.anchor, t.focus) = (after_a, after_b); - assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); - assert_eq!(st.selection, expected, "{text} {a}->{b}"); - assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); - if after_a == after_b { - assert_eq!( - s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), - json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) - ); - } - } - } - - #[test] - fn find_scope_change_other_story_and_no_hits_preserve_selection() { - for explicit in [false, true] { - let (mut s, sid) = scope_session("éX"); - let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); - let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); - let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, sid, 3, 2); - let before = s.doc().unwrap().selection.clone(); - let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); - if explicit { - p["story"] = json!(other_sid.0); - } - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); - assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); - assert_eq!(s.doc().unwrap().selection, before); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); - assert_eq!(s.doc().unwrap().selection, before); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); - assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); - - s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); - let before = s.doc().unwrap().selection.clone(); - assert!(before.text.is_none()); - p["find"] = json!("🙂"); - p["change"] = json!("é"); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - assert_eq!(s.doc().unwrap().selection, before); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); - assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); - } - } - - #[test] - fn find_scope_mixed_grep_matches_count_next_and_change() { - for (text, pattern, expected, changed) in [ - ("a", "a*", vec![(0, 1)], "X"), - ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), - ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), - ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), - ] { - let (mut s, sid) = scope_session(text); - scope_select(&mut s, sid, 0, text.len()); - let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); - let counted = s.execute("find.find", &p).unwrap(); - let ranges: Vec<_> = - counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); - assert_eq!(ranges, expected, "{pattern} on {text}"); - for (a, b) in &expected { - scope_hit(&mut s, &p, sid, *a, *b); - } - for _ in 0..2 { - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); - } - assert_eq!(s.execute("find.find", &p).unwrap(), counted); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); - } - } - - #[test] - fn find_scope_capture_replacement_and_first_keep_original_domain() { - let (mut s, sid) = scope_session("a1 a2 | a3"); - scope_select(&mut s, sid, 0, 5); - let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); - scope_hit(&mut s, &p, sid, 0, 2); - scope_hit(&mut s, &p, sid, 3, 5); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); - } - - #[test] - fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { - for (a, b) in [(0, 7), (7, 0)] { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, a, b); - let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let revision = s.doc().unwrap().revision; - let history = s.doc().unwrap().history.undo.len(); - let strong = std::sync::Arc::strong_count(&before); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - assert_eq!(s.doc().unwrap().selection, selection); - assert_eq!(s.doc().unwrap().revision, revision); - assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); - scope_hit(&mut s, &p, sid, 0, 3); - scope_hit(&mut s, &p, sid, 4, 7); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); - let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!(changed.text, "blue blue | red"); - assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); - assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); - assert!(s.find_context.is_none()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - assert!(s.find_context.is_none()); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); - let native = s.execute("file.serialize", &json!({})).unwrap(); - s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); - assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); - assert!(s.find_context.is_none()); - } - } - - #[test] - fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { - let (mut s, sid) = scope_session("red blue red | blue red"); - scope_select(&mut s, sid, 0, 12); - let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); - scope_hit(&mut s, &p, sid, 0, 3); - p["change"] = json!("two"); - p["attrs"] = json!({"fontStyle": "Italic"}); - scope_hit(&mut s, &p, sid, 9, 12); - p["find"] = json!("blue"); - scope_hit(&mut s, &p, sid, 4, 8); - p["find"] = json!("red"); - scope_hit(&mut s, &p, sid, 0, 3); - p["caseSensitive"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); - p["grep"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. - p["find"] = json!("r.d"); - scope_hit(&mut s, &p, sid, 0, 3); - p["wholeWord"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); - let hit = s.doc().unwrap().selection.text.unwrap(); - scope_select(&mut s, sid, hit.anchor, hit.focus); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); - scope_hit(&mut s, &p, sid, 0, 3); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - scope_select(&mut s, sid, 20, 23); - p["find"] = json!("red"); - scope_hit(&mut s, &p, sid, 20, 23); - } - - #[test] - fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "change": "blue", "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - scope_hit(&mut s, &p, sid, 0, 3); - let selected = s.doc().unwrap().selection.clone(); - let frame = selected.text.unwrap().frame.unwrap(); - let point = { - let st = s.doc().unwrap(); - let cs = s.cache.get(&st.doc, sid, None); - let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); - let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); - assert_eq!(cs.frames[fi].frame, frame); - assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); - let loc = st.doc.find(frame).unwrap(); - st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner - }; - let before = s.doc().unwrap().doc.clone(); - let revision = s.doc().unwrap().revision; - let history = s.doc().unwrap().history.undo.len(); - s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); - assert!(!s.text_drag, "explicit word reselection does not start a text drag"); - assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); - assert!(s.doc().unwrap().revision > revision, "a new revision"); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert!(s.find_context.is_none()); - assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); - assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); - } - - #[test] - fn find_scope_story_explicit_story_document_order_and_scope_changes() { - let (mut s, a) = scope_session("black red"); - let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); - let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, a, 0, 9); - let story = json!({"find": "red", "scope": "story"}); - assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); - scope_select(&mut s, b, 0, 0); - let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); - scope_hit(&mut s, &explicit, a, 0, 5); - let selection = s.doc().unwrap().selection.clone(); - assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); - assert_eq!(s.doc().unwrap().selection, selection); - scope_select(&mut s, a, 0, 9); - scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); - let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); - assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); - assert_eq!(counted[1]["story"], b.0); - scope_select(&mut s, a, 0, 9); - scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); - assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); - s.execute("selection.set", &json!({"ids": []})).unwrap(); - for command in ["find.find", "find.next", "find.change"] { - assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); - } - } - - #[test] - fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { - let (mut s, sid) = scope_session("scat é🙂 red"); - scope_select(&mut s, sid, 1, 4); - let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); - scope_hit(&mut s, &p, sid, 1, 4); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); - scope_select(&mut s, sid, 1, 4); - let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); - scope_hit(&mut s, &p, sid, 1, 2); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); - scope_select(&mut s, sid, 5, 11); - let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); - for pos in [5, 7, 11] { - scope_hit(&mut s, &empty, sid, pos, pos); - } - for _ in 0..2 { - assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); - } - assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); - scope_select(&mut s, sid, 7, 7); - scope_hit(&mut s, &empty, sid, 7, 7); - assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); - scope_select(&mut s, sid, 12, 15); - let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); - scope_hit(&mut s, &nonempty, sid, 12, 15); - assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); - for range in [6..7, 0..usize::MAX] { - let st = s.doc_mut().unwrap(); - st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { - story: sid, - anchor: range.start, - focus: range.end, - frame: None, - cell: None, - }); - st.bump_revision(); - for command in ["find.find", "find.next", "find.change"] { - let before = s.doc().unwrap().doc.clone(); - let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); - assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - } - } - } - - #[test] - fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "scope": "selection"}); - scope_hit(&mut s, &p, sid, 0, 3); - let selected = s.doc().unwrap().selection.clone(); - assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); - assert_eq!(s.doc().unwrap().selection, selected); - scope_hit(&mut s, &p, sid, 4, 7); - assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); - } - - #[test] - fn find_scope_document_switch_edit_and_reset() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "scope": "selection"}); - scope_hit(&mut s, &p, sid, 0, 3); - s.execute("file.new", &json!({})).unwrap(); - s.set_active(0); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); - scope_select(&mut s, sid, 0, 7); - scope_hit(&mut s, &p, sid, 0, 3); - s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); - assert!(s.find_context.is_none()); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); - s.reset_find_scope(); - assert!(s.find_context.is_none()); - } - - #[test] - fn find_scope_real_preview_restores_before_resolving_bounds() { - let (mut s, sid) = scope_session(""); - s.execute( - "data.source.select", - &json!({ - "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" - }), - ) - .unwrap(); - s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); - let template = s.doc().unwrap().doc.clone(); - assert_eq!(template.story(sid).unwrap().text, "<>"); - s.execute("data.preview", &json!({"record": 1})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - // Install a viewport selection in the real preview. text.select is journaled - // and would itself restore the template before selecting. - let st = s.doc_mut().unwrap(); - st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); - st.bump_revision(); - let p = json!({"find": "red", "change": "blue", "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - let history = s.doc().unwrap().history.undo.len(); - let err = s.execute("find.change", &p).unwrap_err(); - assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); - assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<>"); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert!(s.doc().unwrap().preview_stash.is_none()); - assert!(s.find_context.is_none()); - // Valid fresh bounds are allowed after restoration; no cached preview range - // survives. A document query can see the preview, but Next sees the template. - s.execute("data.preview", &json!({"record": 1})).unwrap(); - let document = json!({"find": "red"}); - assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); - assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); - assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); - assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - } - - #[test] - fn document_scope_searches_and_changes_table_cells() { - let (mut s, sid) = scope_session("Ore 12.15 here"); - scope_select(&mut s, sid, 0, 0); - let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap(); - // The caret sits in the first cell, so Find Next goes to the second cell, then the story. - let p = json!({"find": "Ore 12.15", "scope": "document"}); - let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 }; - assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}})); - assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell)))); - let next = s.execute("find.next", &p).unwrap(); - assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}"); - let hits = s.execute("find.find", &p).unwrap(); - let hits = hits.as_array().unwrap(); - assert_eq!(hits.len(), 2); - let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect(); - assert_eq!(in_cell.len(), 1); - assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1))); - let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap(); - assert_eq!(n["count"], 2); - assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty()); - assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); - } - - fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { - let (mut s, sid) = scope_session("red"); - scope_select(&mut s, sid, 3, 3); - if note { - s.execute("footnote.insert", &json!({"text": child})).unwrap(); - } else { - s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); - s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); - } - let selected = s.doc().unwrap().selection.text.unwrap(); - (s, sid, selected) - } - - /// Child-story scopes are rejected by every find command, leaving the document untouched. - fn assert_child_scope_requests_rejected(s: &mut Session) { - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let history = s.doc().unwrap().history.undo.len(); - let revision = s.doc().unwrap().revision; - let journal = s.journal.clone(); - for scope in ["story", "selection"] { - for command in ["find.find", "find.next", "find.change"] { - let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); - assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); - assert!(err.to_string().contains("not supported")); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().selection, selection); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert_eq!(s.doc().unwrap().revision, revision); - assert_eq!(s.journal, journal); - } - } - } - - #[test] - fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { - for note in [false, true] { - for child in ["red", "red x", "é red red red red red"] { - let (mut s, sid, mut selected) = child_scope_session(note, child); - selected.anchor = 0; - selected.focus = child.len(); - s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); - assert_child_scope_requests_rejected(&mut s); - let selection = s.doc().unwrap().selection.clone(); - for explicit in [false, true] { - let mut p = json!({"find": "red", "change": "blue"}); - if explicit { - p["story"] = json!(sid.0); - p["scope"] = json!("selection"); - } - // Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level. - let in_cell = if explicit || note { 0 } else { child.matches("red").count() }; - let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() }; - s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell); - scope_hit(&mut s, &p, sid, 0, 3); - s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell); - assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed); - assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); - p["find"] = json!("blue"); - p["change"] = json!("red"); - s.execute("find.change", &p).unwrap(); - } - } - } - let (mut s, _, _) = child_scope_session(false, "red"); - s.execute("table.selectTable", &json!({})).unwrap(); - for scope in ["story", "selection"] { - for command in ["find.find", "find.next", "find.change"] { - assert!( - s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported") - ); - } - } - } - - #[test] - fn find_scope_threaded_overset_story_uses_logical_offsets() { - let text = format!("{}red", "word ".repeat(100)); - let (mut s, sid) = scope_session(&text); - let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; - let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); - let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); - s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); - scope_select(&mut s, sid, 0, 0); - scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); - assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); - } - - #[test] - fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { - for scope in ["story", "explicit", "document"] { - let (mut s, sid) = scope_session("é🙂"); - scope_select(&mut s, sid, 0, 0); - let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); - if scope == "explicit" { - p["story"] = json!(sid.0); - } - for pos in [0, 2, 6] { - scope_hit(&mut s, &p, sid, pos, pos); - } - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - } - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn find_scope_revert_releases_context_with_same_document_uid() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("scope.designcraft"); - let (mut s, sid) = scope_session("red red | red"); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "scope": "selection"}); - scope_hit(&mut s, &p, sid, 0, 3); - let uid = s.doc().unwrap().uid; - s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); - scope_select(&mut s, sid, 0, 7); - scope_hit(&mut s, &p, sid, 0, 3); - s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().uid, uid); - assert!(s.find_context.is_none()); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); - } -} +mod tests; /// Objects matching Find/Change › Object criteria, in document order (nested ones included). fn find_objects(d: &designcraft_doc::Document, p: &Value) -> Vec { diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs new file mode 100644 index 0000000000..47d685e195 --- /dev/null +++ b/crates/engine/src/cmd/find/tests.rs @@ -0,0 +1,745 @@ +use serde_json::json; + +use crate::Session; + +#[test] +fn find_and_change_text_and_grep() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Call 555-1234 or 555-9876.\nColour and colour."})) + .unwrap(); + let m = s.execute("find.find", &json!({"find": "colour"})).unwrap(); + assert_eq!(m.as_array().unwrap().len(), 2); + let m = s.execute("find.find", &json!({"find": "colour", "caseSensitive": true})).unwrap(); + assert_eq!(m.as_array().unwrap().len(), 1); + let r = s.execute("find.change", &json!({"find": r"(\d{3})-(\d{4})", "change": "($1) $2", "grep": true})).unwrap(); + assert_eq!(r["count"], 2); + let r = s.execute("find.change", &json!({"find": "colour", "change": "color", "attrs": {"fontStyle": "Italic"}})).unwrap(); + assert_eq!(r["count"], 2); + let d = s.execute("document.inspect", &json!({})).unwrap(); + let sid = d["stories"][0]["id"].as_u64().unwrap(); + let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(text, "Call (555) 1234 or (555) 9876.\ncolor and color."); + // Paragraph metacharacter in text mode. + assert_eq!(s.execute("find.find", &json!({"find": ".^pc"})).unwrap().as_array().unwrap().len(), 1); + s.execute("find.next", &json!({"find": "or"})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some()); +} + +#[test] +fn change_first_changes_one_match_in_the_whole_document() { + for grep in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for y in [36, 336] { + s.execute("frame.create", &json!({"rect": [36, y, 300, y + 200], "content": "text", "text": "red and red"})).unwrap(); + } + let r = s.execute("find.change", &json!({"find": "red", "change": "blue", "first": true, "grep": grep})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!(s.execute("find.find", &json!({"find": "blue"})).unwrap().as_array().unwrap().len(), 1); + assert_eq!(s.execute("find.find", &json!({"find": "red"})).unwrap().as_array().unwrap().len(), 3); + } +} + +fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) +} + +fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { + s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); +} + +fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { + assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); +} + +#[test] +fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { + for (texts, first, explicit, grep, expected, count) in [ + (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), + (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), + (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), + (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), + (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), + (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), + (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), + ] { + let (mut s, sid) = scope_session(texts[0]); + let mut sids = vec![sid]; + for text in texts.iter().skip(1) { + let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); + sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); + } + assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::>(), sids); + // Document Change keeps canonical target order even with a later story current. + scope_select(&mut s, *sids.last().unwrap(), 0, 0); + let before = s.doc().unwrap().doc.clone(); + let mut p = json!({ + "find": if grep { "(?:)" } else { "red" }, + "change": if grep { "X" } else { "blue" }, + "scope": "document", "grep": grep + }); + if let Some(first) = first { + p["first"] = json!(first); + } + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { + let last = *sids.last().unwrap(); + scope_hit(&mut s, &p, last, 0, 3); + let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; + assert_eq!( + s.execute("find.find", &p).unwrap(), + json!([ + {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, + {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, + {"story": last.0, "start": 0, "end": 3, "text": "red"} + ]), + "Count remains canonical after Next paints the later story" + ); + } + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.selection, selection); + for (i, sid) in sids.iter().enumerate() { + assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); + if texts[i] == expected[i] { + assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); + } + } + st.doc.check().unwrap(); + } + + // Identical replacement is still one counted match, not a reason to visit another story. + let (mut s, sid) = scope_session("red red"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, other_sid, 0, 3); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); + assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); + assert_eq!(s.doc().unwrap().selection, selection); + s.doc().unwrap().doc.check().unwrap(); + + for reverse in [false, true] { + let (mut s, sid) = scope_session("éX X"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); + let before = s.doc().unwrap().doc.clone(); + let mut selection = s.doc().unwrap().selection.clone(); + let t = selection.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; + let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); + assert_eq!(st.doc.story(other_sid), before.story(other_sid)); + assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); + st.doc.check().unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) + ); + } +} + +#[test] +fn grep_in_the_documents_dialect() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Москва and Paris, 1\u{2013}2 days.\nok"})).unwrap(); + // `\u` uppercase, `\l` lowercase letter. + let m = s.execute("find.find", &json!({"find": r"\u\l+", "grep": true, "caseSensitive": true})).unwrap(); + let words: Vec<&str> = m.as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect(); + assert_eq!(words, ["Москва", "Paris"]); + // `~=` en dash, `\h` space, `\r` paragraph end; the change uses groups, `~S` and `\r`. + let r = s.execute("find.change", &json!({"find": r"(\d)~=(\d)\h(\l+)\.\r", "change": r"$1~=$2~S$3!\r", "grep": true})).unwrap(); + assert_eq!(r["count"], 1); + let sid = s.execute("document.inspect", &json!({})).unwrap()["stories"][0]["id"].as_u64().unwrap(); + let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.starts_with("Москва and Paris, 1\u{2013}2\u{A0}days!\nok"), "{text:?}"); + // A pattern that doesn't compile is an error, not a silent no-op. + assert!(s.execute("find.find", &json!({"find": "[[:nope:]]", "grep": true})).is_err()); + assert!(s.execute("find.change", &json!({"find": "(", "change": "", "grep": true})).is_err()); +} + +#[test] +fn find_scope_exact_baseline_count_next_change() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s + .execute( + "frame.create", + &json!({ + "rect": [36, 36, 300, 200], "content": "text", "text": "red red" + }), + ) + .unwrap(); + let sid = frame["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), + json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) + ); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); + assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); +} + +#[test] +fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { + for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { + for reverse in [false, true] { + let (mut s, sid) = scope_session(text); + // Real caret movement keeps the frame identity, unlike text.select. + s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); + if !reverse { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); + assert!(selected.frame.is_some()); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; + let p = json!({"find": find, "change": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed); + assert_eq!(st.selection, expected); + let t = st.selection.text.unwrap(); + assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); + assert!(s.find_context.is_none()); + let p = json!({"find": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); + scope_hit(&mut s, &p, sid, start, new_end); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } +} + +#[test] +fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { + for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ + ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. + ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. + ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. + ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), + ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. + ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), + ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. + ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. + ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. + ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), + ("abc", (1, 3), "abc", "", "", (0, 0), 1), + ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, a, b); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = (after_a, after_b); + assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); + assert_eq!(st.selection, expected, "{text} {a}->{b}"); + assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); + if after_a == after_b { + assert_eq!( + s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) + ); + } + } +} + +#[test] +fn find_scope_change_other_story_and_no_hits_preserve_selection() { + for explicit in [false, true] { + let (mut s, sid) = scope_session("éX"); + let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, 3, 2); + let before = s.doc().unwrap().selection.clone(); + let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); + if explicit { + p["story"] = json!(other_sid.0); + } + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.is_none()); + p["find"] = json!("🙂"); + p["change"] = json!("é"); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); + } +} + +#[test] +fn find_scope_mixed_grep_matches_count_next_and_change() { + for (text, pattern, expected, changed) in [ + ("a", "a*", vec![(0, 1)], "X"), + ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), + ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), + ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, 0, text.len()); + let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); + let counted = s.execute("find.find", &p).unwrap(); + let ranges: Vec<_> = + counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); + assert_eq!(ranges, expected, "{pattern} on {text}"); + for (a, b) in &expected { + scope_hit(&mut s, &p, sid, *a, *b); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); + } + assert_eq!(s.execute("find.find", &p).unwrap(), counted); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); + } +} + +#[test] +fn find_scope_capture_replacement_and_first_keep_original_domain() { + let (mut s, sid) = scope_session("a1 a2 | a3"); + scope_select(&mut s, sid, 0, 5); + let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); + scope_hit(&mut s, &p, sid, 0, 2); + scope_hit(&mut s, &p, sid, 3, 5); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); +} + +#[test] +fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { + for (a, b) in [(0, 7), (7, 0)] { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, a, b); + let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + let strong = std::sync::Arc::strong_count(&before); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); + scope_hit(&mut s, &p, sid, 0, 3); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); + let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(changed.text, "blue blue | red"); + assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + assert!(s.find_context.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.find_context.is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); + let native = s.execute("file.serialize", &json!({})).unwrap(); + s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); + assert!(s.find_context.is_none()); + } +} + +#[test] +fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { + let (mut s, sid) = scope_session("red blue red | blue red"); + scope_select(&mut s, sid, 0, 12); + let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); + scope_hit(&mut s, &p, sid, 0, 3); + p["change"] = json!("two"); + p["attrs"] = json!({"fontStyle": "Italic"}); + scope_hit(&mut s, &p, sid, 9, 12); + p["find"] = json!("blue"); + scope_hit(&mut s, &p, sid, 4, 8); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 0, 3); + p["caseSensitive"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + p["grep"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. + p["find"] = json!("r.d"); + scope_hit(&mut s, &p, sid, 0, 3); + p["wholeWord"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + let hit = s.doc().unwrap().selection.text.unwrap(); + scope_select(&mut s, sid, hit.anchor, hit.focus); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 20, 23); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 20, 23); +} + +#[test] +fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = s.doc().unwrap().doc.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert!(!s.text_drag, "explicit word reselection does not start a text drag"); + assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); + assert!(s.doc().unwrap().revision > revision, "a new revision"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); +} + +#[test] +fn find_scope_story_explicit_story_document_order_and_scope_changes() { + let (mut s, a) = scope_session("black red"); + let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); + let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, a, 0, 9); + let story = json!({"find": "red", "scope": "story"}); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); + scope_select(&mut s, b, 0, 0); + let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); + scope_hit(&mut s, &explicit, a, 0, 5); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); + assert_eq!(s.doc().unwrap().selection, selection); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); + let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); + assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); + assert_eq!(counted[1]["story"], b.0); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); + } +} + +#[test] +fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { + let (mut s, sid) = scope_session("scat é🙂 red"); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 4); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 2); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); + scope_select(&mut s, sid, 5, 11); + let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); + for pos in [5, 7, 11] { + scope_hit(&mut s, &empty, sid, pos, pos); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + } + assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); + scope_select(&mut s, sid, 7, 7); + scope_hit(&mut s, &empty, sid, 7, 7); + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 12, 15); + let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); + scope_hit(&mut s, &nonempty, sid, 12, 15); + assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); + for range in [6..7, 0..usize::MAX] { + let st = s.doc_mut().unwrap(); + st.selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: range.start, focus: range.end, frame: None, cell: None }); + st.bump_revision(); + for command in ["find.find", "find.next", "find.change"] { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + } +} + +#[test] +fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); + assert_eq!(s.doc().unwrap().selection, selected); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); +} + +#[test] +fn find_scope_document_switch_edit_and_reset() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + s.reset_find_scope(); + assert!(s.find_context.is_none()); +} + +#[test] +fn find_scope_real_preview_restores_before_resolving_bounds() { + let (mut s, sid) = scope_session(""); + s.execute( + "data.source.select", + &json!({ + "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" + }), + ) + .unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); + let template = s.doc().unwrap().doc.clone(); + assert_eq!(template.story(sid).unwrap().text, "<>"); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + // Install a viewport selection in the real preview. text.select is journaled + // and would itself restore the template before selecting. + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); + st.bump_revision(); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + let history = s.doc().unwrap().history.undo.len(); + let err = s.execute("find.change", &p).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<>"); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.doc().unwrap().preview_stash.is_none()); + assert!(s.find_context.is_none()); + // Valid fresh bounds are allowed after restoration; no cached preview range + // survives. A document query can see the preview, but Next sees the template. + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let document = json!({"find": "red"}); + assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); + assert_eq!(s.doc().unwrap().history.undo.len(), history); +} + +#[test] +fn document_scope_searches_and_changes_table_cells() { + let (mut s, sid) = scope_session("Ore 12.15 here"); + scope_select(&mut s, sid, 0, 0); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap(); + // The caret sits in the first cell, so Find Next goes to the second cell, then the story. + let p = json!({"find": "Ore 12.15", "scope": "document"}); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 }; + assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}})); + assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell)))); + let next = s.execute("find.next", &p).unwrap(); + assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}"); + let hits = s.execute("find.find", &p).unwrap(); + let hits = hits.as_array().unwrap(); + assert_eq!(hits.len(), 2); + let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect(); + assert_eq!(in_cell.len(), 1); + assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1))); + let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap(); + assert_eq!(n["count"], 2); + assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty()); + assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); +} + +fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { + let (mut s, sid) = scope_session("red"); + scope_select(&mut s, sid, 3, 3); + if note { + s.execute("footnote.insert", &json!({"text": child})).unwrap(); + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + (s, sid, selected) +} + +/// Child-story scopes are rejected by every find command, leaving the document untouched. +fn assert_child_scope_requests_rejected(s: &mut Session) { + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let history = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + let journal = s.journal.clone(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(err.to_string().contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.journal, journal); + } + } +} + +#[test] +fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { + for note in [false, true] { + for child in ["red", "red x", "é red red red red red"] { + let (mut s, sid, mut selected) = child_scope_session(note, child); + selected.anchor = 0; + selected.focus = child.len(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); + assert_child_scope_requests_rejected(&mut s); + let selection = s.doc().unwrap().selection.clone(); + for explicit in [false, true] { + let mut p = json!({"find": "red", "change": "blue"}); + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + // Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level. + let in_cell = if explicit || note { 0 } else { child.matches("red").count() }; + let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() }; + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell); + scope_hit(&mut s, &p, sid, 0, 3); + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed); + assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); + p["find"] = json!("blue"); + p["change"] = json!("red"); + s.execute("find.change", &p).unwrap(); + } + } + } + let (mut s, _, _) = child_scope_session(false, "red"); + s.execute("table.selectTable", &json!({})).unwrap(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported")); + } + } +} + +#[test] +fn find_scope_threaded_overset_story_uses_logical_offsets() { + let text = format!("{}red", "word ".repeat(100)); + let (mut s, sid) = scope_session(&text); + let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; + let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); + let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); + scope_select(&mut s, sid, 0, 0); + scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); +} + +#[test] +fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { + for scope in ["story", "explicit", "document"] { + let (mut s, sid) = scope_session("é🙂"); + scope_select(&mut s, sid, 0, 0); + let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); + if scope == "explicit" { + p["story"] = json!(sid.0); + } + for pos in [0, 2, 6] { + scope_hit(&mut s, &p, sid, pos, pos); + } + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn find_scope_revert_releases_context_with_same_document_uid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scope.designcraft"); + let (mut s, sid) = scope_session("red red | red"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let uid = s.doc().unwrap().uid; + s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().uid, uid); + assert!(s.find_context.is_none()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index d67f469f0b..5ec6abb4dc 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -6,2536 +6,17 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; use crate::{Result, Session}; -/// A start page number parameter: a whole number in 1..=[`designcraft_doc::MAX_PAGE_NUMBER`]. -fn start_number(cmd: &str, key: &str, v: &Value) -> Result { - v.as_u64() - .and_then(|n| u32::try_from(n).ok()) - .filter(|n| (1..=designcraft_doc::MAX_PAGE_NUMBER).contains(n)) - .ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number from 1 to {}", designcraft_doc::MAX_PAGE_NUMBER))) -} +mod layers; +mod margins; +mod pages; +mod parents; +mod registry; +mod sections; -/// Each page's margin box, by spread and page id. -fn margin_boxes(d: &designcraft_doc::Document) -> Vec<(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)> { - d.spread_refs() - .filter_map(|r| d.spread(r).map(|sp| (r, sp))) - .flat_map(|(r, sp)| sp.pages.iter().map(move |pg| (r, pg.id, pg.margin_rect()))) - .collect() -} - -/// Layout Adjustment: objects on each page move and resize from its old margin box to its new -/// one (content keeps its size; frames that auto-fit refit). -fn adjust_layout(d: &mut designcraft_doc::Document, before: &[(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)]) { - use designcraft_geom::Affine; - let after = margin_boxes(d); - for (r, pid, old) in before { - let Some((_, _, new)) = after.iter().find(|(r2, p2, _)| r2 == r && p2 == pid) else { continue }; - if old == new || old.width() < 1e-6 || old.height() < 1e-6 { - continue; - } - let m = Affine::translate((new.x0, new.y0)) - * Affine::scale_non_uniform(new.width() / old.width(), new.height() / old.height()) - * Affine::translate((-old.x0, -old.y0)); - let Some(sp) = d.spread(*r) else { continue }; - // The page's objects (by centre, measured against the old boxes). - let ids: Vec = sp - .items - .iter() - .filter(|it| { - let c = it.bounds().center(); - // Pages share a spread side by side: the margin box's page is the one under the centre's x. - before - .iter() - .filter(|(r2, _, _)| r2 == r) - .min_by(|a, b| dist(a.2, c.x).total_cmp(&dist(b.2, c.x))) - .is_some_and(|(_, p2, _)| p2 == pid) - }) - .map(|it| it.id) - .collect(); - for id in ids { - let Some(it) = d.item_mut(id) else { continue }; - if matches!(it.content, designcraft_doc::Content::Group { .. }) { - it.xf = m * it.xf; - } else { - let inner = it.xf.inverse() * m * it.xf; - it.path.transform(inner); - if let Some(w) = &mut it.wrap.path { - w.transform(inner); - } - let ib = it.inner_bounds(); - if let designcraft_doc::Content::Graphic(g) = &mut it.content { - // Content keeps its size but moves with the frame; auto-fit refits. - let o = inner * designcraft_geom::Point::ZERO; - g.xf = Affine::translate(o.to_vec2()) * g.xf; - if let Some(xf) = g.fitted(ib, g.auto_fit) { - g.xf = xf; - } - } - } - } - } -} - -/// Horizontal distance from `x` to a margin box (0 inside). -fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { - if x < r.x0 { - r.x0 - x - } else if x > r.x1 { - x - r.x1 - } else { - 0.0 - } -} - -/// 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(_)) => { - if let Ok(m) = serde_json::from_value(v.clone()) { - pg.margins = m; - } - } - _ => {} - } - 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) { - 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).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 { - vec![ - cmd!( - "layout.pages.insert", - "Insert Pages…", - ["Layout", "Pages"], - Some("Cmd+Shift+P"), - "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}", - has_doc, - |s, p| { - const ID: &str = "layout.pages.insert"; - // A count that is given but is not a number of pages used to insert one page: - // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped. - let count = match p.get("count").filter(|v| !v.is_null()) { - None => 1, - Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) { - Some(n) => n, - None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))), - }, - }; - let st = s.doc()?; - let n = st.doc.page_count(); - // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end` - // pins deliberately. - let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); - let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); - s.edit(|d, _| { - let ids = d.insert_pages(Some(after), count, parent)?; - Ok(json!({"pages": ids.len(), "total": d.page_count()})) - }) - } - ), - cmd!( - "layout.rotateSpreadView", - "Rotate Spread", - [], - None, - "{spread?: index (0), angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn one spread on screen (output is unaffected)", - has_doc, - |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - let angle = match p.get("angle") { - None => 90, - Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?, - }; - if angle % 90 != 0 { - return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees")); - } - s.edit(|d, _| { - let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?; - let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?; - pg.view_rotation = if angle == 0 { 0 } else { ((pg.view_rotation as i64 + angle.div_euclid(90)).rem_euclid(4)) as u8 }; - Ok(json!({"rotation": pg.view_rotation as u32 * 90})) - }) - } - ), - cmd!("layout.pages.delete", "Delete Pages", ["Layout", "Pages"], None, "{pages: [index]}", has_doc, |s, p| { - let pages: Vec = p - .get("pages") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) - .unwrap_or_default(); - s.edit(|d, sel| { - d.delete_pages(&pages)?; - sel.items.retain(|i| d.item(*i).is_some()); - sel.text = sel.text.filter(|t| d.story(t.story).is_some()); - ok() - }) - }), - cmd!("layout.pages.move", "Move Pages…", ["Layout", "Pages"], None, "{from, to}", has_doc, |s, p| { - let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing from"))? as usize; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing to"))? as usize; - s.edit(|d, _| { - d.move_page(from, to)?; - ok() - }) - }), - cmd!( - "layout.pages.duplicateSpread", - "Duplicate Spread", - ["Layout", "Pages"], - None, - "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", - has_doc, - |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); - let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); - s.edit(|d, _| { - let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; - // The copies land at `to`..`to + n` of the document with them in it. - if to.is_some_and(|t| t > d.page_count()) { - return Err(bad("layout.pages.duplicateSpread", "no such page")); - } - let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; - let parent = sp.pages.first().and_then(|p| p.parent); - d.insert_pages(Some(after), sp.pages.len(), parent)?; - // The copies are the pages they copy: size, margins, columns, guides, liquid rule. - for (k, src) in sp.pages.iter().enumerate() { - let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; - let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); - let pg = &mut nsp.pages[npi]; - let dx = pg.x - src.x; - // 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; - } - nsp.relayout(); - } - // Copy items onto the new spread(s) at the same positions. - let src = d.clone(); - let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; - super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - let first = after + 1; - let Some(to) = to else { return Ok(json!({"page": first})) }; - // Moving a page shifts the ones between; order the moves so each copy keeps - // its place among the others. - let n = sp.pages.len(); - if first < to { - for k in (0..n).rev() { - d.move_page(first + k, to + k)?; - } - } else { - for k in 0..n { - d.move_page(first + k, to + k)?; - } - } - Ok(json!({"page": to})) - }) - } - ), - cmd!( - "layout.pages.applyParent", - "Apply Parent to Pages…", - ["Layout", "Pages"], - None, - "{pages: [index], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)}", - has_doc, - |s, p| { - let pages: Vec = p - .get("pages") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) - .unwrap_or_default(); - let parent = parent_ref("layout.pages.applyParent", &s.doc()?.doc, p.get("parent"))?.unwrap_or(None); - s.edit(|d, _| { - d.apply_parent(&pages, parent)?; - ok() - }) - } - ), - cmd!("layout.parents.new", "New Parent…", ["Layout", "Pages"], None, "{prefix?, name?: \"Parent\"}", has_doc, |s, p| { - 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 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})) - }) - }), - cmd!( - "layout.parents.delete", - "Delete Parent", - [], - None, - "{id: parent spread id} — unapply the parent and keep local overrides as ordinary items", - has_doc, - |s, p| { - let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?; - s.edit(|d, sel| { - d.delete_parent(id)?; - *sel = Default::default(); - ok() - })?; - let st = s.doc_mut()?; - if st.doc.parents.is_empty() { - st.editing_parents = false; - } - ok() - } - ), - cmd!( - noundo "layout.parents.edit", - "Edit Parents", - [], - None, - "{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent", - has_doc, - |s, p| { - const ID: &str = "layout.parents.edit"; - let doc = s.doc()?.doc.clone(); - let parent = match p.get("parent") { - None | Some(Value::Null) => None, - v => match parent_ref(ID, &doc, v)? { - Some(Some(id)) => Some(id), - _ => return Err(bad(ID, "`parent` names a parent to show")), - }, - }; - let st = s.doc_mut()?; - let was = st.editing_parents; - let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was); - st.editing_parents = on; - st.selection = Default::default(); - st.bump_revision(); - // The parent to show: the one asked for, or the first when editing starts. - let index = match parent { - Some(id) => doc.parent_index(id), - None if !was && !doc.parents.is_empty() => Some(0), - None => None, - }; - let shown = index.filter(|_| on); - if let Some(i) = shown { - s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) }); - } - Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)})) - } - ), - cmd!( - "layout.parents.options", - "Parent Options…", - [], - None, - "{parent: id|\"B-Parent\"|\"B\", basedOn: id|\"A-Parent\"|\"A\"|null (= [None])} — the parent shows its base's items under its own, and so do the pages that use it → {parent, basedOn}", - has_doc, - parent_options - ), - cmd!( - "layout.marginsAndColumns", - "Margins and Columns…", - ["Layout"], - None, - "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins), parent? (a parent's prefix, label or index)} — document pages take these as their own margins; with `parent` the parent's pages change instead (`pages` then indexes them), and so do the document pages without their own", - has_doc, - |s, p| { - let pages: Option> = - p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); - let parent = match p.get("parent") { - None | Some(Value::Null) => None, - Some(v) => Some(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad("layout.marginsAndColumns", "no such parent"))?), - }; - let p = p.clone(); - let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); - s.edit(|d, _| { - let before = adjust.then(|| margin_boxes(d)); - if let Some(pi) = parent { - let n = d.parents.get(pi).map_or(0, |sp| sp.pages.len()); - let sp = d.parents.get_mut(pi).map(std::sync::Arc::make_mut); - if let Some(sp) = sp { - for qi in pages.clone().unwrap_or_else(|| (0..n).collect()) { - if let Some(pg) = sp.pages.get_mut(qi) { - set_margins_columns(pg, &p); - } - } - } - d.sync_parent_margins(); - } else { - let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); - for abs in all { - let Some((si, pi)) = d.page_loc(abs) else { continue }; - let Some(pg) = d.spreads.get_mut(si).map(std::sync::Arc::make_mut).and_then(|sp| sp.pages.get_mut(pi)) else { continue }; - set_margins_columns(pg, &p); - pg.local_margins = true; - } - } - if let Some(b) = before { - adjust_layout(d, &b); - } - ok() - }) - } - ), - cmd!( - "layout.moveColumnGuide", - "Move Column Guide", - [], - None, - "{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked", - column_guides_unlocked, - move_column_guide - ), - cmd!( - "layout.documentSetup", - "Document Setup…", - ["File"], - Some("Cmd+Alt+P"), - "{width?, height?, pages?: count, startPage?: n, facingPages?, binding?: leftToRight|rightToLeft, intent?: print|web|mobile, margins?: n | {top, bottom, inside, outside} (every page and parent page), bleed?, slug?: n | [top, bottom, inside, outside], adjustLayout?: bool (objects follow the new page size and margins)} → the document setup (margins: the first page's)", - has_doc, - |s, p| { - let p = p.clone(); - if p.as_object().is_none_or(|o| o.is_empty()) { - return Ok(document_setup(&s.doc()?.doc)); - } - let edges = |k: &str| -> Option<[f64; 4]> { - match p.get(k)? { - Value::Array(a) if a.len() == 4 => { - let v: Vec = a.iter().filter_map(Value::as_f64).map(|v| v.max(0.0)).collect(); - (v.len() == 4).then(|| [v[0], v[1], v[2], v[3]]) - } - v => v.as_f64().map(|b| [b.max(0.0); 4]), - } - }; - let (bleed, slug) = (edges("bleed"), edges("slug")); - for k in ["width", "height"] { - if let Some(v) = p.get(k) - && !v.as_f64().is_some_and(|x| x > 0.0 && x <= 15552.0) - { - return Err(bad("layout.documentSetup", format!("{k}: page size out of range (0 < size ≤ 216 in)"))); - } - } - let margins: Option = match p.get("margins") { - None => None, - Some(Value::Number(n)) => n.as_f64().map(Margins::uniform), - Some(v) => serde_json::from_value(v.clone()).ok(), - }; - if margins.is_some_and(|m| ![m.top, m.bottom, m.inside, m.outside].iter().all(|v| v.is_finite() && (0.0..=15552.0).contains(v))) { - return Err(bad("layout.documentSetup", "margins: 0 to 216 in")); - } - if p.get("margins").is_some() && margins.is_none() { - return Err(bad("layout.documentSetup", "margins: a number or {top, bottom, inside, outside}")); - } - let intent: Option = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()); - let pages = p.get("pages").and_then(Value::as_u64).map(|n| n.clamp(1, 9999) as usize); - let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); - s.edit(|d, _| { - let before = adjust.then(|| margin_boxes(d)); - if let Some(f) = p.get("facingPages").and_then(Value::as_bool) { - d.settings.facing_pages = f; - } - if let Some(b) = p.get("binding").and_then(Value::as_str) { - d.settings.right_to_left_binding = b == "rightToLeft"; - d.order_parent_pages(); - } - if let Some(b) = bleed { - d.settings.bleed = b; - } - if let Some(b) = slug { - d.settings.slug = b; - } - if let Some(i) = intent { - d.settings.intent = i; - } - if let Some(n) = p.get("startPage").map(|v| start_number("layout.documentSetup", "startPage", v)).transpose()? { - if !d.sections.iter().any(|x| x.start == 0) { - d.sections.insert( - 0, - designcraft_doc::Section { - start: 0, - start_number: None, - style: Default::default(), - prefix: String::new(), - marker: String::new(), - include_prefix: false, - }, - ); - } - if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) { - first.start_number = Some(n); - } - } - // Number of Pages: add at the end (with the last page's parent) or remove from the end. - if let Some(n) = pages { - let have = d.page_count(); - if n > have { - let parent = d.page_loc(have - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); - d.insert_pages(Some(have - 1), n - have, parent)?; - } else if n < have { - d.delete_pages(&(n..have).collect::>())?; - } - } - let w = p.get("width").and_then(Value::as_f64); - let h = p.get("height").and_then(Value::as_f64); - // Pages with a liquid rule re-flow their objects (Adjust Layout aside). - let liquid_before: Vec<(usize, usize, designcraft_geom::Rect)> = if adjust { - vec![] - } else { - d.spreads - .iter() - .enumerate() - .flat_map(|(si, sp)| { - sp.pages.iter().enumerate().filter(|(_, pg)| !pg.liquid.is_off()).map(move |(pi, pg)| (si, pi, pg.bounds())) - }) - .collect() - }; - if w.is_some() || h.is_some() { - d.settings.page_width = w.unwrap_or(d.settings.page_width); - d.settings.page_height = h.unwrap_or(d.settings.page_height); - 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 { - set_page_width(pg, d.settings.page_width); - pg.height = d.settings.page_height; - } - sp.relayout(); - } - } - if let Some(m) = margins { - for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { - for pg in &mut std::sync::Arc::make_mut(sp).pages { - pg.margins = m; - } - } - // Pages that now match their parent page follow its later changes; local - // columns stay local. - d.unmark_matching_local_margins(); - d.sync_parent_margins(); - } - d.repaginate(); - if let Some(b) = before { - adjust_layout(d, &b); - } - for (si, pi, old) in liquid_before { - super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, old); - } - Ok(document_setup(d)) - }) - } - ), - cmd!( - "layout.pages.toSpread", - "Add Page to Spread", - ["Layout", "Pages"], - None, - "{page (1-based), spread (0-based)} — the page joins that spread (up to 10 pages; the spread stops shuffling)", - has_doc, - |s, p| { - let page = - p.get("page").and_then(Value::as_u64).filter(|n| *n >= 1).ok_or_else(|| bad("layout.pages.toSpread", "missing page"))? as usize; - let spread = p.get("spread").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.toSpread", "missing spread"))? as usize; - s.edit(|d, _| { - d.page_to_spread(page - 1, spread)?; - Ok(json!({"spreads": d.spreads.len()})) - }) - } - ), - cmd!( - "layout.spreadShuffle", - "Allow Selected Spread to Shuffle", - ["Layout", "Pages"], - None, - "{spread (0-based), allow: bool} — off keeps the spread's pages together when pages are added or removed", - has_doc, - |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - let allow = p.get("allow").and_then(Value::as_bool).unwrap_or(true); - s.edit(|d, _| { - let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.spreadShuffle", "no such spread"))?; - std::sync::Arc::make_mut(sp).allow_shuffle = allow; - if allow { - d.repaginate(); - } - ok() - }) - } - ), - cmd!( - "layout.pageSize", - "Page Size", - [], - None, - "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", - has_doc, - page_size - ), - cmd!( - "layout.pageOptions", - "Page Options", - [], - None, - "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", - has_doc, - page_options - ), - cmd!( - noundo "layout.selectPage", - "Select Page", - [], - None, - "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", - has_doc, - |s, p| { - let n = s.doc()?.doc.page_count(); - let page = match p.get("page") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), - }; - let ids: Vec = { - let d = &s.doc()?.doc; - p.get("ids") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) - .unwrap_or_default() - }; - let st = s.doc_mut()?; - if page.is_some() { - st.selection = designcraft_doc::Selection::items(ids); - } - st.selection.page = page; - st.bump_revision(); - Ok(json!({"page": page.map(|p| p + 1)})) - } - ), - cmd!( - "layout.section", - "Numbering & Section Options…", - ["Layout"], - None, - "{page (1-based; starts a section there), startNumber?: n (1–99999)|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits|kanji, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", - has_doc, - |s, p| { - let edit = SectionEdit::parse("layout.section", s, p)?; - s.edit(|d, _| { - edit.apply(d); - Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>()})) - }) - } - ), - cmd!( - "layout.chapterNumbering", - "Document Chapter Numbering", - [], - None, - "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, start?: n (Start Chapter Numbering at), source?: automatic|userDefined|sameAsPrevious} → {chapterNumber, label, style, source, book: the open book holding the document, or null}; no parameters reports the setting", - has_doc, - |s, p| { - if p.as_object().is_none_or(|o| o.is_empty()) { - return chapter_numbering(s); - } - let edit = ChapterEdit::parse("layout.chapterNumbering", p)?; - s.edit(|d, _| { - edit.apply(d); - ok() - })?; - chapter_numbering(s) - } - ), - cmd!( - "layout.numberingOptions", - "Apply Numbering & Section Options", - [], - None, - "{page, …the settings as layout.section, chapter?: {…the settings as layout.chapterNumbering}} — the section and the document's chapter numbering in one undo step; if either is refused nothing changes", - has_doc, - |s, p| { - let section = SectionEdit::parse("layout.numberingOptions", s, p)?; - let chapter = match p.get("chapter") { - Some(c) => Some(ChapterEdit::parse("layout.numberingOptions", c)?), - None => None, - }; - s.edit(|d, _| { - section.apply(d); - if let Some(chapter) = &chapter { - chapter.apply(d); - } - Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>(), "chapter": d.chapter_label()})) - }) - } - ), - cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing id"))?); - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing to"))?; - s.edit(|d, _| { - let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?; - let to = usize::try_from(to).ok().filter(|t| *t < d.layers.len()).ok_or_else(|| bad("layer.move", "to out of range"))?; - let l = d.layers.remove(i); - d.layers.insert(to, l); - ok() - }) - }), - cmd!(noundo "layer.selectItems", "Select All on Layer", [], None, "{id}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - let st = s.doc_mut()?; - let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect(); - st.selection = designcraft_doc::Selection::items(ids); - st.bump_revision(); - ok() - }), - cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| { - let name = str_param(p, "name").map(str::to_string); - let r = s.edit(|d, _| { - let id = LayerId(d.alloc()); - let n = d.layers.len(); - let name = name.clone().unwrap_or_else(|| format!("Layer {}", n + 1)); - d.layers.insert( - 0, - Layer { - id, - name, - color: LAYER_COLORS[n % LAYER_COLORS.len()].1, - visible: true, - locked: false, - printable: true, - show_guides: true, - suppress_wrap_when_hidden: false, - }, - ); - Ok(id) - })?; - s.doc_mut()?.active_layer = r; - Ok(json!({"id": r.0})) - }), - cmd!( - "layer.set", - "Layer Options…", - [], - None, - "{id, name?, color?: [r,g,b] | layer colour name (\"Light Blue\", \"Red\", …), visible?, locked?, printable?, showGuides?, suppressWrapWhenHidden?} — names are unique and not empty", - has_doc, - set_layer - ), - cmd!("layer.delete", "Delete Layer", [], None, "{id}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - s.edit(|d, sel| { - if d.layers.len() < 2 { - return Err(bad("layer.delete", "a document needs at least one layer")); - } - let doomed: Vec<_> = - d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().filter(|i| i.layer == id).map(|i| i.id)).collect(); - for i in doomed { - let _ = d.remove_item(i); - } - d.layers.retain(|l| l.id != id); - *sel = Default::default(); - ok() - }) - }), - cmd!( - "layer.merge", - "Merge Layers", - [], - None, - "{ids: [layer ids], into?: layer id (default: the first)} — their objects move to `into`, the other layers are deleted", - has_doc, - |s, p| { - let ids: Vec = - p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(LayerId).collect()).unwrap_or_default(); - let into = p - .get("into") - .and_then(Value::as_u64) - .map(LayerId) - .or_else(|| ids.first().copied()) - .ok_or_else(|| bad("layer.merge", "no layers"))?; - s.edit(|d, _| { - if d.layer(into).is_none() || ids.iter().any(|l| d.layer(*l).is_none()) { - return Err(bad("layer.merge", "no such layer")); - } - let gone: Vec = ids.iter().copied().filter(|l| *l != into).collect(); - for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { - if !sp.items.iter().any(|i| gone.contains(&i.layer)) { - continue; - } - for it in &mut std::sync::Arc::make_mut(sp).items { - if gone.contains(&it.layer) { - std::sync::Arc::make_mut(it).layer = into; - } - } - } - d.layers.retain(|l| !gone.contains(&l.id)); - Ok(json!({"merged": gone.len()})) - }) - } - ), - cmd!("layer.deleteUnused", "Delete Unused Layers", [], None, "{} — layers without objects (one layer always stays)", has_doc, |s, _| { - s.edit(|d, _| { - let used: std::collections::HashSet = - d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().map(|i| i.layer)).collect(); - let before = d.layers.len(); - if used.is_empty() { - // Nothing anywhere: keep the first layer. - d.layers.truncate(1); - } else { - d.layers.retain(|l| used.contains(&l.id)); - } - Ok(json!({"deleted": before - d.layers.len()})) - }) - }), - cmd!( - "layer.others", - "Hide/Lock Others", - [], - None, - "{id, hide?: bool, lock?: bool, show?: true (Show All Layers), unlock?: true (Unlock All Layers)} — Hide Others / Lock Others act on every layer but `id`", - has_doc, - |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); - let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock")); - s.edit(|d, _| { - for l in &mut d.layers { - if show { - l.visible = true; - } - if unlock { - l.locked = false; - } - if l.id != id { - if hide { - l.visible = false; - } - if lock { - l.locked = true; - } - } - } - ok() - }) - } - ), - cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - let st = s.doc_mut()?; - if st.doc.layer(id).is_none() { - return Err(bad("layer.activate", "no such layer")); - } - st.active_layer = id; - st.bump_revision(); - ok() - }), - ] -} - -/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column -/// Guides is off. -fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - match s.active() { - Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()), - _ => Ok(()), - } -} - -/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`. -fn move_column_guide(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "layout.moveColumnGuide"; - let index = |key: &str| -> Result { - let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?; - v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer"))) - }; - let (page, gutter) = (index("page")?, index("gutter")?); - let position = - p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?; - let r = match p.get("parent") { - None | Some(Value::Null) => super::spread_param(p, CMD)?, - Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?), - }; - s.edit(|d, _| { - if d.settings.column_guides_locked { - return Err(bad(CMD, "column guides are locked")); - } - let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?; - let m = pg.margin_rect(); - let count = pg.columns.count; - if gutter.checked_add(1).is_none_or(|g| g >= count as usize) { - return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1)))); - } - let positions = - pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?; - let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v); - pg.columns.positions = Some(positions.clone()); - match r { - designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true, - designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(), - } - Ok(json!({"positions": positions, "left": left})) - }) -} - -/// A parent spread named by prefix, label (`A-Parent`) or index. -fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option { - match v { - Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < d.parents.len()), - Value::String(s) => d.parents.iter().position(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)), - _ => None, - } -} - -/// A parent named by a command parameter: its id (a number), its full label (`"A-Parent"`), or -/// its prefix (`"A"`) when only one parent has it. `null` or `"[None]"` → `Some(None)` (no parent); -/// missing → `None` (the command's default). Unknown or ambiguous names are errors, so a typo never -/// clears a page's parent. -fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Result>> { - let Some(v) = v else { return Ok(None) }; - match v { - Value::Null => Ok(Some(None)), - Value::String(s) if s == "[None]" => Ok(Some(None)), - Value::Number(n) => { - let id = n.as_u64().ok_or_else(|| bad(cmd, format!("no parent with id {n}")))?; - d.parents.iter().find(|p| p.id.0 == id).map(|p| Some(Some(p.id))).ok_or_else(|| bad(cmd, format!("no parent with id {id}"))) - } - Value::String(s) => { - let by = |f: &dyn Fn(&designcraft_doc::ParentInfo) -> bool| -> Vec { - d.parents.iter().filter(|p| p.parent.as_ref().is_some_and(f)).map(|p| p.id).collect() - }; - let mut hits = by(&|i| i.label() == *s); - if hits.is_empty() { - hits = by(&|i| i.prefix == *s); - } - match hits.as_slice() { - [id] => Ok(Some(Some(*id))), - [] => Err(bad(cmd, format!("no parent named `{s}`"))), - _ => Err(bad(cmd, format!("more than one parent is named `{s}`: give its id"))), - } - } - _ => Err(bad(cmd, "`parent` is a parent id, a parent name or null")), - } -} - -/// Layout › Pages › Parent Options: Based on Parent. A base that would make a cycle is refused. -fn parent_options(s: &mut crate::Session, p: &Value) -> Result { - const CMD: &str = "layout.parents.options"; - let d = &s.doc()?.doc; - let Some(Some(id)) = parent_ref(CMD, d, p.get("parent"))? else { return Err(bad(CMD, "`parent` names the parent to change")) }; - let pi = d.parent_index(id).ok_or_else(|| bad(CMD, format!("no parent with id {}", id.0)))?; - let base = parent_ref(CMD, d, p.get("basedOn"))?.ok_or_else(|| bad(CMD, "missing `basedOn` (a parent, or null for [None])"))?; - let label = |d: &designcraft_doc::Document, i: usize| d.parents.get(i).and_then(|x| x.parent.as_ref()).map(|i| i.label()); - if let Some(b) = base - && d.parent_base_cycles(pi, b) - { - let (name, b) = (label(d, pi).unwrap_or_default(), d.parent_index(b).and_then(|i| label(d, i)).unwrap_or_default()); - return Err(bad(CMD, format!("{name} cannot be based on {b}: that would make a cycle"))); - } - let out = json!({"parent": label(d, pi), "basedOn": base.and_then(|b| d.parent_index(b)).and_then(|i| label(d, i))}); - if d.parents.get(pi).and_then(|x| x.parent.as_ref()).and_then(|i| i.based_on) == base { - return Ok(out); - } - s.edit(|d, _| { - let info = d.parents.get_mut(pi).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()).ok_or_else(|| bad(CMD, "no such parent"))?; - info.based_on = base; - Ok(out) - }) -} - -#[allow(dead_code)] -fn _r(_: Result<()>) {} - -/// Layout › Page Size (the Page tool): resize pages one by one. Left pages grow to the left -/// (the spine stays put); objects move with their page. -fn page_size(s: &mut crate::Session, p: &Value) -> Result { - let pages: Vec = - p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); - let n = s.doc()?.doc.page_count(); - if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { - return Err(bad("layout.pageSize", "give `pages` (1-based)")); - } - let (mut w, mut h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); - if let Some(name) = str_param(p, "preset") { - let pr = designcraft_doc::build::PRESETS - .iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("layout.pageSize", format!("no preset `{name}`")))?; - w = Some(pr.width); - h = Some(pr.height); - } - let anchor = match p.get("anchor") { - None | Some(Value::Null) => 0, - Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, - }; - s.edit(|d, _| { - resize_pages_from(d, &pages, w, h, anchor); - Ok(json!({"pages": pages.len()})) - }) -} - -fn page_options(s: &mut crate::Session, p: &Value) -> Result { - let pages: Vec = - p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); - let n = s.doc()?.doc.page_count(); - if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { - return Err(bad("layout.pageOptions", "give `pages` (1-based)")); - } - let overlay = p.get("showParentOverlay").and_then(Value::as_bool); - s.edit(|d, _| { - for page in &pages { - let Some((si, pi)) = d.page_loc(page - 1) else { continue }; - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; - if let Some(v) = overlay { - pg.show_parent_overlay = v; - } - } - Ok(json!({"pages": pages.len()})) - }) -} - -/// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule -/// re-flow their objects by it. -pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { - resize_pages_from(d, pages, w, h, 0); -} - -/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: -/// the page grows away from it, so objects keep their place relative to it. -pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { - let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); - { - for page in pages { - let Some((si, pi)) = d.page_loc(page - 1) else { continue }; - let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); - let old: Vec = sp.pages.iter().map(|x| x.x).collect(); - let old_bounds = sp.pages[pi].bounds(); - // The page's own objects (outer pages take the pasteboard beside them). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); - let own: Vec = sp - .items - .iter() - .enumerate() - .filter(|(_, it)| { - let c = it.bounds().center().x; - c >= lo && c < hi - }) - .map(|(i, _)| i) - .collect(); - let pg = &mut sp.pages[pi]; - 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 { - set_page_width(pg, w.max(1.0)); - } - if let Some(h) = h { - pg.height = h.max(1.0); - } - let left = pg.side == designcraft_doc::PageSide::Left; - sp.relayout(); - // Keep the spine: shift the whole spread back when a left page grows. - if left && dw != 0.0 { - for q in &mut sp.pages { - q.x -= dw; - } - } - let new: Vec = sp.pages.iter().map(|x| x.x).collect(); - // Objects follow their page. - let shifts: Vec<(f64, f64, f64)> = - sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); - for it in &mut sp.items { - let cx = it.bounds().center().x; - if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) - && *dx != 0.0 - { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; - } - } - // Page guides move with their page. - for (k, q) in sp.pages.iter_mut().enumerate() { - let dx = new[k] - old[k]; - if dx != 0.0 { - for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { - g.position += dx; - } - } - } - // Growing away from the anchor: the page's objects and guides keep their place - // relative to it. - let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); - if shift != designcraft_geom::Vec2::ZERO { - for i in &own { - if let Some(it) = sp.items.get_mut(*i) { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate(shift) * it.xf; - } - } - for g in &mut sp.pages[pi].guides { - g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; - } - } - // Liquid rules: objects were carried along with the page's left edge (and the anchor). - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; - super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); - } - } -} - -/// File › Document Setup values. -fn document_setup(d: &designcraft_doc::Document) -> Value { - let st = &d.settings; - let start = d.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).unwrap_or(1); - json!({"width": st.page_width, "height": st.page_height, "pages": d.page_count(), "startPage": start, "facingPages": st.facing_pages, - "binding": if st.right_to_left_binding { "rightToLeft" } else { "leftToRight" }, "intent": st.intent, "bleed": st.bleed, "slug": st.slug, - "margins": d.page(0).map(|p| p.margins), "primaryTextFrame": st.primary_text_frame}) -} - -/// The largest chapter number (as for page numbers). -const MAX_CHAPTER: u64 = designcraft_doc::MAX_PAGE_NUMBER as u64; - -/// A `layout.section` edit, checked before the document changes. -struct SectionEdit { - /// Absolute index of the section's first page. - start: usize, - remove: bool, - /// Some(None): continue from the previous section. - start_number: Option>, - style: Option, - prefix: Option, - include_prefix: Option, - marker: Option, -} - -impl SectionEdit { - fn parse(cmd: &str, s: &Session, p: &Value) -> Result { - let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))?; - let n = s.doc()?.doc.page_count() as u64; - if page == 0 || page > n { - return Err(bad(cmd, format!("no page {page}"))); - } - let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string); - if prefix.as_ref().is_some_and(|x| x.chars().count() > 8 || x.contains(['+', ','])) { - return Err(bad(cmd, "section prefix: up to 8 characters, without + or ,")); - } - let start_number = match p.get("startNumber") { - None => None, - Some(Value::Null) => Some(None), - Some(v) => Some(Some(start_number(cmd, "startNumber", v)?)), - }; - let style = match p.get("style") { - None => None, - Some(v) => match serde_json::from_value(v.clone()) { - Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad(cmd, "symbols don't number pages")), - Ok(style) => Some(style), - Err(e) => return Err(bad(cmd, e.to_string())), - }, - }; - Ok(SectionEdit { - start: (page - 1) as usize, - remove: p.get("remove").and_then(Value::as_bool).unwrap_or(false), - start_number, - style, - prefix, - include_prefix: p.get("includePrefix").and_then(Value::as_bool), - marker: p.get("marker").and_then(Value::as_str).map(str::to_string), - }) - } - - fn apply(&self, d: &mut designcraft_doc::Document) { - let start = self.start; - // Page 1 always starts a section. - if self.remove && start != 0 { - d.sections.retain(|x| x.start != start); - // The pages after it follow the previous section's numbers again. - d.repaginate(); - return; - } - let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { - start, - start_number: None, - style: Default::default(), - prefix: String::new(), - marker: String::new(), - include_prefix: false, - }); - if let Some(n) = self.start_number { - sec.start_number = n; - } - if let Some(style) = self.style { - sec.style = style; - } - if let Some(prefix) = &self.prefix { - sec.prefix = prefix.clone(); - } - if let Some(on) = self.include_prefix { - sec.include_prefix = on; - } - if let Some(marker) = &self.marker { - sec.marker = marker.clone(); - } - d.sections.retain(|x| x.start != start); - d.sections.push(sec); - d.sections.sort_by_key(|x| x.start); - // Facing pages take their sides from their numbers: odd on the right. - d.repaginate(); - } -} - -/// A `layout.chapterNumbering` edit, checked before the document changes. -struct ChapterEdit { - style: Option, - source: Option, - start: Option, -} - -impl ChapterEdit { - fn parse(cmd: &str, p: &Value) -> Result { - let style = match p.get("style") { - Some(v) => match serde_json::from_value::(v.clone()) { - Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => return Err(bad(cmd, format!("unknown chapter style {v}"))), - Ok(style) => Some(style), - }, - None => None, - }; - let mut source = match p.get("source") { - Some(v) => Some( - serde_json::from_value::(v.clone()) - .map_err(|_| bad(cmd, format!("unknown chapter number source {v}")))?, - ), - None => None, - }; - let start = match p.get("start") { - Some(v) => { - Some(v.as_u64().filter(|n| (1..=MAX_CHAPTER).contains(n)).ok_or_else(|| bad(cmd, format!("chapter start: 1 to {MAX_CHAPTER}")))? - as u32) - } - None => None, - }; - // A start number alone means Start Chapter Numbering at. - if start.is_some() && source.is_none() { - source = Some(designcraft_doc::ChapterSource::UserDefined); - } - Ok(ChapterEdit { style, source, start }) - } - - fn apply(&self, d: &mut designcraft_doc::Document) { - if let Some(style) = self.style { - d.settings.chapter_style = style; - } - if let Some(source) = self.source { - d.settings.chapter_source = source; - } - if let Some(n) = self.start { - d.settings.chapter_number = n; - } - } -} - -/// The active document's chapter numbering, and the open book that holds it (what -/// `layout.chapterNumbering {}` reports, read without running a command). -pub fn chapter_numbering(s: &Session) -> Result { - let st = s.doc()?; - let set = &st.doc.settings; - let book = s - .book - .as_ref() - .filter(|b| st.path.as_deref().is_some_and(|p| b.documents.iter().any(|d| std::path::Path::new(d) == std::path::Path::new(p)))) - .map(|b| std::path::Path::new(&b.path).file_stem().map_or_else(|| b.path.clone(), |n| n.to_string_lossy().to_string())); - Ok( - json!({"chapterNumber": set.chapter_number.max(1), "label": st.doc.chapter_label(), "style": set.chapter_style, "source": set.chapter_source, "book": book}), - ) -} - -/// Layer Options: every field in one undo step; a bad name or colour changes nothing. -fn set_layer(s: &mut Session, p: &Value) -> Result { - const ID: &str = "layer.set"; - let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); - let name = match p.get("name") { - Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "a layer needs a name"))?.to_string()), - None => None, - }; - let color: Option<[u8; 3]> = match p.get("color") { - None => None, - Some(Value::String(n)) => Some( - LAYER_COLORS - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(n)) - .map(|(_, c)| *c) - .ok_or_else(|| bad(ID, format!("unknown layer colour `{n}`")))?, - ), - Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(ID, "color is [r,g,b] (0–255) or a layer colour name"))?), - }; - let flag = |k: &str| match p.get(k) { - None => Ok(None), - Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(ID, format!("`{k}` is true or false"))), - }; - let (visible, locked, printable) = (flag("visible")?, flag("locked")?, flag("printable")?); - let (guides, wrap) = (flag("showGuides")?, flag("suppressWrapWhenHidden")?); - s.edit(|d, _| { - if let Some(n) = &name - && d.layers.iter().any(|l| l.id != id && l.name == *n) - { - return Err(bad(ID, format!("a layer named `{n}` exists"))); - } - let l = d.layer_mut(id).ok_or_else(|| bad(ID, "no such layer"))?; - if let Some(n) = name { - l.name = n; - } - if let Some(c) = color { - l.color = c; - } - for (slot, v) in [ - (&mut l.visible, visible), - (&mut l.locked, locked), - (&mut l.printable, printable), - (&mut l.show_guides, guides), - (&mut l.suppress_wrap_when_hidden, wrap), - ] { - if let Some(v) = v { - *slot = v; - } - } - ok() - }) -} - -#[cfg(test)] -mod setup_tests { - use serde_json::json; - - #[test] - fn chapter_numbering_sets_style_and_start() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("layout.chapterNumbering", &json!({})).unwrap(); - assert_eq!(r, json!({"chapterNumber": 1, "label": "1", "style": "arabic", "source": "automatic", "book": null})); - let r = s.execute("layout.chapterNumbering", &json!({"start": 4, "style": "upperRoman"})).unwrap(); - assert_eq!((&r["label"], &r["source"]), (&json!("IV"), &json!("userDefined")), "a start number means Start Chapter Numbering at"); - s.execute("variables.define", &json!({"name": "Chapter", "type": "chapterNumber"})).unwrap(); - let d = &s.doc().unwrap().doc; - let chapter = d.variable("Chapter").unwrap(); - assert_eq!(d.variable_value(chapter, Some(0)).as_deref(), Some("IV"), "the Chapter Number variable uses the style"); - for bad in [json!({"style": "symbols"}), json!({"start": 0}), json!({"start": 100_000}), json!({"source": "nextBook"})] { - assert!(s.execute("layout.chapterNumbering", &bad).is_err(), "{bad}"); - } - s.execute("layout.chapterNumbering", &json!({"source": "sameAsPrevious"})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.execute("layout.chapterNumbering", &json!({})).unwrap()["source"], "userDefined", "undoable"); - } - - #[test] - fn document_setup_sets_every_pages_margins_in_one_step() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 3, "margins": 36})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text"})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({})).unwrap(); - assert_eq!(r["margins"], json!({"top": 36.0, "bottom": 36.0, "inside": 36.0, "outside": 36.0})); - let m = json!({"top": 72, "bottom": 54, "inside": 72, "outside": 72}); - let r = s.execute("layout.documentSetup", &json!({"margins": m, "bleed": 9, "adjustLayout": true})).unwrap(); - assert_eq!(r["margins"]["bottom"], 54.0); - let d = &s.doc().unwrap().doc; - assert!( - d.spreads.iter().chain(&d.parents).flat_map(|sp| &sp.pages).all(|pg| pg.margins.top == 72.0 && pg.margins.bottom == 54.0), - "every page and parent page" - ); - let b = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap().bounds(); - assert!((b.y0 - 72.0).abs() < 1e-6, "Adjust Layout: the frame follows the margins ({b:?})"); - // One step: undo brings back the margins and the bleed. - s.execute("edit.undo", &json!({})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({})).unwrap(); - assert_eq!((r["margins"]["top"].clone(), r["bleed"][0].clone()), (json!(36.0), json!(0.0))); - for bad in [ - json!({"width": 0}), - json!({"height": -5}), - json!({"width": 1e9}), - json!({"width": "wide"}), - json!({"margins": -1}), - json!({"margins": {"top": 1}}), - ] { - assert!(s.execute("layout.documentSetup", &bad).is_err(), "{bad}"); - } - assert_eq!(s.execute("layout.documentSetup", &json!({})).unwrap()["width"], 612.0, "refused values change nothing"); - } - - #[test] - fn document_setup_pages_start_bleed_slug() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({})).unwrap(); - assert_eq!(r["pages"], 2); - assert_eq!(r["startPage"], 1); - let r = s.execute("layout.documentSetup", &json!({"pages": 5, "startPage": 2, "bleed": 9, "slug": [0, 18, 0, 0], "intent": "web"})).unwrap(); - assert_eq!(r["pages"], 5); - assert_eq!(r["startPage"], 2); - assert_eq!(r["bleed"], json!([9.0, 9.0, 9.0, 9.0])); - assert_eq!(r["slug"], json!([0.0, 18.0, 0.0, 0.0])); - assert_eq!(r["intent"], "web"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_name(0), "2"); - assert_eq!(d.page(0).unwrap().side, designcraft_doc::PageSide::Left); - assert_eq!(d.spreads[0].pages.len(), 2, "pages 2–3 form the first spread"); - let r = s.execute("layout.documentSetup", &json!({"pages": 3})).unwrap(); - assert_eq!(r["pages"], 3); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn right_to_left_binding_reverses_spreads() { - use designcraft_doc::PageSide::{Left, Right}; - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - // A frame on page 2 stays on page 2. - let w = s.doc().unwrap().doc.settings.page_width; - let f = s.execute("frame.create", &json!({"rect": [w + 72.0, 72, w + 200.0, 200], "content": "text", "text": "two"})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); - assert_eq!(r["binding"], "rightToLeft"); - let d = &s.doc().unwrap().doc; - let shape: Vec> = d.spreads.iter().map(|sp| sp.pages.iter().map(|p| (p.side, p.x)).collect()).collect(); - // Page 1 alone on the left; pages 2–3 with 2 on the right; page 4 alone on the right. - assert_eq!(shape, vec![vec![(Left, 0.0)], vec![(Right, w), (Left, 0.0)], vec![(Right, 0.0)]]); - let (si, _) = d.page_loc(1).unwrap(); - assert_eq!(si, 1); - let it = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap(); - assert_eq!(d.spreads[si].page_at_x(it.bounds().center().x), Some(0), "the frame is still on page 2"); - assert!(it.bounds().x0 >= w, "page 2 is the right page now"); - d.check().unwrap(); - // And back. - s.execute("layout.documentSetup", &json!({"binding": "leftToRight"})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]); - } - - /// Bound right to left, a parent spread lists its right page first; each document page still - /// shows the parent page on its own side, at its own position, also after an IDML round trip. - #[test] - fn right_to_left_binding_shows_the_parent_page_of_the_same_side() { - use designcraft_doc::PageSide::{Left, Right}; - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); - let w = s.doc().unwrap().doc.settings.page_width; - let parent = json!({"kind": "parent", "index": 0}); - for (rect, text) in [(json!([72, 36, 200, 60]), "left head"), (json!([w + 72.0, 36, w + 200.0, 60]), "right head")] { - s.execute("frame.create", &json!({"spread": parent, "rect": rect, "content": "text", "text": text})).unwrap(); - } - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.parents[0].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Right, w), (Left, 0.0)]); - let docs = [(*d).clone(), designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap()]; - for d in docs { - let mut s = crate::Session::new(); - s.add_document(crate::DocState::new(d, None)); - for (page, side, text) in [(0, Left, "left head"), (1, Right, "right head"), (2, Left, "left head"), (3, Right, "right head")] { - let r = s.execute("layout.overrideParentItems", &json!({"page": page})).unwrap(); - let d = &s.doc().unwrap().doc; - let ids = r["ids"].as_array().unwrap(); - assert_eq!(ids.len(), 1, "page {}", page + 1); - let it = d.item(designcraft_doc::ItemId(ids[0].as_u64().unwrap())).unwrap(); - let story = d.story(it.text_frame().unwrap().story).unwrap(); - assert_eq!((d.page(page).unwrap().side, story.text.trim_end()), (side, text), "page {}", page + 1); - assert_eq!(d.page_of_item(it.id), Some(page), "page {}", page + 1); - } - } - } - - /// Multi-column frames filled the left column first in a right-to-left document (#100): - /// stories made in a right-to-left-bound document start their columns on the right. - #[test] - fn new_stories_follow_the_binding_direction() { - use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; - let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n"); - for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); - s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap(); - s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}"); - let cs = s.cache.get(&st.doc, sid, None); - let fr = &cs.frames[0]; - let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap()); - assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns); - assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column"); - // An empty frame given text content starts a story too. - let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap(); - s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap(); - let d = &s.doc().unwrap().doc; - let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content"); - } - } -} - -#[cfg(test)] -mod layer_tests { - use serde_json::json; - - #[test] - fn layer_move_reorders_and_rejects_bad_input() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let l1 = s.doc().unwrap().doc.layers[0].id.0; - let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); - let order = |s: &crate::Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::>(); - assert_eq!(order(&s), [l2, l1]); - for bad in [json!({"id": l1, "to": 2}), json!({"id": 999, "to": 0}), json!({"id": l1}), json!({"to": 0})] { - assert!(s.execute("layer.move", &bad).is_err(), "{bad}"); - assert_eq!(order(&s), [l2, l1]); - } - s.execute("layer.move", &json!({"id": l1, "to": 0})).unwrap(); - assert_eq!(order(&s), [l1, l2]); - } - - #[test] - fn merge_delete_unused_hide_and_lock_others() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let l1 = s.doc().unwrap().doc.layers[0].id.0; - let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); - let l3 = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); - s.execute("layer.activate", &json!({"id": l2})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layer.others", &json!({"id": l2, "hide": true, "lock": true})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.layers.iter().all(|l| (l.id.0 == l2) == l.visible && (l.id.0 != l2) == l.locked)); - s.execute("layer.others", &json!({"id": l2, "show": true, "unlock": true})).unwrap(); - assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.visible && !l.locked)); - let r = s.execute("layer.deleteUnused", &json!({})).unwrap(); - assert_eq!(r["deleted"], 2, "the empty default layer and Empty"); - let _ = (l1, l3); - let l4 = s.execute("layer.new", &json!({"name": "Into"})).unwrap()["id"].as_u64().unwrap(); - s.execute("layer.merge", &json!({"ids": [l4, l2]})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.layers.len(), 1); - assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4); - } -} - -#[cfg(test)] -mod page_size_tests { - use serde_json::json; - - #[test] - fn page_size_per_page_keeps_objects_on_their_page() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 3})).unwrap(); - // Page 3 is the right page of spread 2 (pages 2–3); an object on it. - let d = s.doc().unwrap().doc.clone(); - let (si, pi) = d.page_loc(2).unwrap(); - let x0 = d.spreads[si].pages[pi].x; - let id = s.execute("frame.create", &json!({"spread": si, "rect": [x0 + 100.0, 100, x0 + 200.0, 200]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 500})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let left = &d.spreads[si].pages[0]; - assert_eq!((left.width, left.height), (300.0, 500.0)); - assert_eq!(d.spreads[si].pages[1].x, x0, "the spine stays"); - let it = d.item(designcraft_doc::ItemId(id)).unwrap(); - assert_eq!(it.bounds().x0, x0 + 100.0); - assert_eq!(d.page_of_item(it.id), Some(2)); - s.execute("layout.pageSize", &json!({"pages": [3], "preset": "A4"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!((d.spreads[si].pages[1].width - 595.276).abs() < 0.01); - assert!(s.execute("layout.pageSize", &json!({"pages": [9], "width": 100})).is_err()); - } -} - -#[cfg(test)] -mod spread_tests { - use serde_json::json; - - #[test] - fn three_page_spread_survives_page_inserts() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 5})).unwrap(); - // [1] [2 3] [4 5]: page 4 joins spread 1 → [1] [2 3 4] [5]. - let d = s.doc().unwrap().doc.clone(); - let (si, pi) = d.page_loc(3).unwrap(); - let x = d.spreads[si].pages[pi].x; - let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 50.0, 50, x + 150.0, 150]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.pages.toSpread", &json!({"page": 4, "spread": 1})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.spreads[1].pages.len(), 3); - assert!(!d.spreads[1].allow_shuffle); - assert_eq!(d.page_of_item(designcraft_doc::ItemId(id)), Some(3), "the object went with its page"); - // Adding a page at the start reshuffles the others but keeps the 3-page spread. - s.execute("layout.pages.insert", &json!({"after": 0, "count": 1})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.spreads.iter().any(|sp| sp.pages.len() == 3 && !sp.allow_shuffle)); - assert_eq!(d.page_count(), 6); - d.check().unwrap(); - } -} - -#[cfg(test)] -mod page_numbering_view_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn section_and_absolute_page_numbering_view() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - s.execute("layout.section", &json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); - s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); - assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["i", "ii", "1", "2"]); - assert_eq!(s.resolve_page("ii"), Some(1)); - assert_eq!(s.resolve_page("1"), Some(2), "section name first"); - assert_eq!(s.resolve_page("+1"), Some(0), "+n is a position"); - s.execute("prefs.set", &json!({"absolutePageNumbers": true})).unwrap(); - assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["1", "2", "3", "4"]); - assert_eq!(s.resolve_page("1"), Some(0)); - assert_eq!(s.resolve_page("9"), None); - } - - #[test] - fn section_start_numbers_are_bounded() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - for n in [json!(0), json!(100_000), json!(u32::MAX), json!(u64::from(u32::MAX) + 2), json!(-1), json!(2.5), json!("7")] { - let e = s.execute("layout.section", &json!({"page": 1, "startNumber": n})).unwrap_err().to_string(); - assert!(e.contains("startNumber"), "{n}: {e}"); - assert!(s.execute("layout.documentSetup", &json!({"startPage": n})).is_err(), "{n}"); - } - assert_eq!((0..2).map(|i| s.page_label(i)).collect::>(), ["1", "2"]); - let r = s.execute("layout.section", &json!({"page": 1, "startNumber": 99_999})).unwrap(); - assert_eq!(r["names"], json!(["99999", "100000"])); - } - - #[test] - fn numbering_options_apply_in_one_undo_step_or_not_at_all() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - let names = |s: &crate::Session| (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect::>(); - let r = s - .execute( - "layout.numberingOptions", - &json!({"page": 3, "startNumber": 5, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "chapter": {"start": 4, "style": "upperRoman"}}), - ) - .unwrap(); - assert_eq!((r["names"].clone(), r["chapter"].clone()), (json!(["1", "2", "A-v", "A-vi"]), json!("IV"))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(names(&s), ["1", "2", "3", "4"], "one undo reverts the section…"); - assert_eq!(s.doc().unwrap().doc.chapter_label(), "1", "…and the chapter numbering"); - // A refused part leaves the other undone too. - for bad in [ - json!({"page": 3, "startNumber": 5, "chapter": {"start": 100_000}}), - json!({"page": 3, "startNumber": 4_294_967_295u64, "chapter": {"start": 2}}), - json!({"page": 3, "prefix": "A+B", "chapter": {"start": 2}}), - json!({"page": 9, "chapter": {"start": 2}}), - ] { - assert!(s.execute("layout.numberingOptions", &bad).is_err(), "{bad}"); - assert_eq!(names(&s), ["1", "2", "3", "4"], "{bad}"); - assert_eq!(s.doc().unwrap().doc.settings.chapter_source, designcraft_doc::ChapterSource::Automatic, "{bad}"); - } - // Removing a section keeps the chapter part. - s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); - s.execute("layout.numberingOptions", &json!({"page": 3, "remove": true, "chapter": {"source": "userDefined", "start": 2}})).unwrap(); - assert_eq!(names(&s), ["1", "2", "3", "4"]); - assert_eq!(s.doc().unwrap().doc.chapter_label(), "2"); - } - - #[test] - fn section_prefixes_are_short_and_avoid_plus_and_comma() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - for prefix in ["A-", "Appendix", "§ 1"] { - s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).unwrap(); - } - for prefix in ["Appendix1", "A+", "1,2"] { - assert!(s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).is_err(), "{prefix}"); - } - assert!(s.execute("layout.section", &json!({"page": 2, "style": "symbols"})).is_err()); - assert_eq!(s.doc().unwrap().doc.sections.iter().find(|x| x.start == 1).unwrap().prefix, "§ 1", "a refused prefix changes nothing"); - } - - /// `layout.section` edits one named setting; the others stay as the section was (#717). - #[test] - fn updating_a_section_keeps_settings_the_command_does_not_name() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - s.execute( - "layout.section", - &json!({"page": 2, "startNumber": 1, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "marker": "First"}), - ) - .unwrap(); - assert_eq!(s.page_label(1), "A-i"); - s.execute("layout.section", &json!({"page": 2, "marker": "Second"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let sec = d.sections.iter().find(|x| x.start == 1).expect("the page-2 section is still the only one there"); - assert_eq!(sec.marker, "Second"); - assert_eq!(sec.start_number, Some(1), "startNumber is kept"); - assert_eq!(sec.style, designcraft_doc::NumberStyle::LowerRoman, "style is kept"); - assert_eq!(sec.prefix, "A-", "prefix is kept"); - assert!(sec.include_prefix, "includePrefix is kept"); - assert_eq!(s.page_label(1), "A-i"); - } -} - -#[cfg(test)] -mod adjust_tests { - use designcraft_doc::Margins; - use serde_json::json; - - use crate::Session; - - #[test] - fn adjust_layout_follows_the_new_page_size_and_margins() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - // Letter, 36 pt margins: a frame filling the left half of the margin box. - let id = s.execute("frame.create", &json!({"rect": [36, 36, 306, 756]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.documentSetup", &json!({"width": 1224, "adjustLayout": true})).unwrap(); - let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); - assert!((b.x0 - 36.0).abs() < 1e-6 && (b.x1 - 612.0).abs() < 1e-6, "half of the wider margin box: {b:?}"); - s.execute("layout.marginsAndColumns", &json!({"margins": 72, "adjustLayout": true})).unwrap(); - let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); - assert!((b.x0 - 72.0).abs() < 1e-6 && (b.y0 - 72.0).abs() < 1e-6 && (b.y1 - 720.0).abs() < 1e-6, "{b:?}"); - // Without the option objects stay put. - s.execute("layout.marginsAndColumns", &json!({"margins": 36})).unwrap(); - let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); - assert_eq!(b, b2); - } - - #[test] - fn parent_margins_change_the_parent_and_follow_unoverridden_pages() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - // Page 3 has its own margins (an override); pages 1 and 2 still follow the parent. - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); - let parent_before = s.doc().unwrap().doc.parents[0].pages[0].margins; - assert_eq!(parent_before, Margins::uniform(36.0)); - s.execute("layout.marginsAndColumns", &json!({"parent": 0, "margins": 60, "columns": 2})).unwrap(); - let d = &s.doc().unwrap().doc; - // The parent page itself changed (this is what was drawn on screen). - assert_eq!(d.parents[0].pages[0].margins, Margins::uniform(60.0)); - assert_eq!(d.parents[0].pages[0].columns.count, 2); - // Pages without their own margins follow; the page given its own keeps them. - assert_eq!(d.page(0).unwrap().margins, Margins::uniform(60.0)); - assert_eq!(d.page(1).unwrap().margins, Margins::uniform(60.0)); - assert_eq!(d.page(2).unwrap().margins, Margins::uniform(100.0)); - // 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>, width: f64) { - let want: Vec = 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)] -mod section_side_tests { - use serde_json::json; - - use crate::Session; - - /// Each spread's pages as (name, side), side `L`, `R` or `S`. - fn spreads(s: &Session) -> Vec> { - let d = &s.doc().unwrap().doc; - let mut abs = 0; - d.spreads - .iter() - .map(|sp| { - sp.pages - .iter() - .map(|p| { - let side = match p.side { - designcraft_doc::PageSide::Left => 'L', - designcraft_doc::PageSide::Right => 'R', - designcraft_doc::PageSide::Single => 'S', - }; - abs += 1; - (d.page_name(abs - 1), side) - }) - .collect() - }) - .collect() - } - - fn pages(v: &[&[(&str, char)]]) -> Vec> { - v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect() - } - - #[test] - fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() { - // Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the - // section starts on page 8, which becomes a lone right-hand page 1; the pages after it - // shuffle and their objects go with them. Undo puts them back. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap(); - let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"] - .as_u64() - .unwrap(); - let page_of = |s: &Session| { - let d = &s.doc().unwrap().doc; - d.page_of_item(designcraft_doc::ItemId(id)).unwrap() - }; - assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9"); - let before = spreads(&s); - s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap(); - assert_eq!( - spreads(&s), - pages(&[ - &[("1", 'R')], - &[("2", 'L'), ("3", 'R')], - &[("4", 'L'), ("5", 'R')], - &[("6", 'L'), ("7", 'R')], - &[("1", 'R')], - &[("2", 'L'), ("3", 'R')], - ]) - ); - assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spreads(&s), before); - // On a right page (the video's page 9) nothing moves; an even start on a right page moves - // it to the left. - s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap(); - assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]); - s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap(); - assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]])); - // Removing the section numbers the pages on from 9 again. - s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap(); - assert_eq!(spreads(&s), before); - } - - #[test] - fn single_sided_documents_keep_their_pages() { - let mut s = Session::new(); - s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap(); - s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap(); - let sp = spreads(&s); - assert_eq!(sp.len(), 4); - assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}"); - } -} - -#[cfg(test)] -mod spread_rotation_input_tests { - use serde_json::json; - - #[test] - fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation; - for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] { - assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}"); - assert_eq!(rotation(&s), 0, "invalid input must not change the view"); - } - s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap(); - assert_eq!(rotation(&s), 1); - s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap(); - assert_eq!(rotation(&s), 0); - s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap(); - assert_eq!(rotation(&s), 2); - s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap(); - assert_eq!(rotation(&s), 0, "zero resets the spread view"); - } -} - -#[cfg(test)] -mod layer_options_tests { - use serde_json::json; - - #[test] - fn layer_set_sets_every_field_and_rejects_bad_names() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let first = s.doc().unwrap().doc.layers[0].id.0; - let id = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); - let all = json!({"id": id, "name": " Artwork ", "color": "Gold", "visible": false, "locked": true, "printable": false, - "showGuides": false, "suppressWrapWhenHidden": true}); - s.execute("layer.set", &all).unwrap(); - let l = s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).cloned().unwrap(); - assert_eq!(l.name, "Artwork"); - assert_eq!(l.color, [255, 153, 0]); - assert!(!l.visible && l.locked && !l.printable && !l.show_guides && l.suppress_wrap_when_hidden); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).unwrap().name, "Art", "one undo step"); - let first_name = s.doc().unwrap().doc.layers.iter().find(|l| l.id.0 == first).unwrap().name.clone(); - let before = s.doc().unwrap().doc.layers.clone(); - for p in [ - json!({"id": id, "name": ""}), - json!({"id": id, "name": " "}), - json!({"id": id, "name": first_name}), - json!({"id": id, "name": "Fine", "color": "Plaid"}), - json!({"id": id, "color": [300, 0, 0]}), - json!({"id": id, "visible": "yes"}), - json!({"id": 999_999, "name": "Ghost"}), - ] { - assert!(s.execute("layer.set", &p).is_err(), "{p}"); - } - assert_eq!(s.doc().unwrap().doc.layers, before); - // Keeping its own name is fine. - s.execute("layer.set", &json!({"id": id, "name": "Art", "color": [1, 2, 3]})).unwrap(); - } -} - -#[cfg(test)] -mod parent_margin_tests { - use designcraft_doc::{Document, Margins}; - use serde_json::json; - - use crate::Session; - - fn margins(d: &Document) -> Vec<(Margins, u32, bool)> { - (0..d.page_count()).filter_map(|i| d.page(i)).map(|p| (p.margins, p.columns.count, p.local_margins)).collect() - } - - #[test] - fn document_setup_margins_let_pages_follow_their_parent_page_again() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"pages": [1], "margins": 100})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100, "columns": 3})).unwrap(); - s.execute("layout.documentSetup", &json!({"margins": 20})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - let twenty = Margins::uniform(20.0); - assert_eq!(m, vec![(twenty, 1, false), (twenty, 1, false), (twenty, 3, true)], "own columns stay local"); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 50})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[1], (Margins::uniform(50.0), 1, false), "page 2 follows the parent page again"); - assert_eq!(m[2], (twenty, 3, true)); - } - - #[test] - fn pages_follow_their_parent_pages_margins_unless_set_locally() { - let mut s = Session::new(); - // Facing: [1] [2 3]; page 3 gets its own margins. - s.execute("file.new", &json!({"pages": 3})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); - // A's left page: only the left document page follows. - let left = json!({"top": 10, "bottom": 20, "inside": 30, "outside": 40}); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "pages": [0], "margins": left, "columns": 2})).unwrap(); - let l = Margins { top: 10.0, bottom: 20.0, inside: 30.0, outside: 40.0 }; - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m, vec![(Margins::uniform(36.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); - // A's right page: page 1 follows, page 3 keeps its own. - s.execute("layout.marginsAndColumns", &json!({"parent": "A-Parent", "pages": [1], "margins": 50})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m, vec![(Margins::uniform(50.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); - // A page inserted after page 1 pushes page 2 to the right: it takes the right parent page's. - s.execute("layout.pages.insert", &json!({"after": 0, "count": 1, "parent": "A"})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[1], (l, 2, false), "the new left page"); - assert_eq!(m[2], (Margins::uniform(50.0), 1, false), "old page 2, now a right page"); - assert_eq!(m[3], (Margins::uniform(100.0), 1, true)); - assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); - } - - #[test] - fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { - let mut s = Session::new(); - // Facing: [1] [2 3]. - s.execute("file.new", &json!({"pages": 3})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"parent": "B", "margins": 70, "columns": 3})).unwrap(); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "the page takes B's margins"); - // Page 3 is given margins equal to A's: they are its own all the same. - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 36})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 20})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[0], (Margins::uniform(20.0), 1, false)); - assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "B's page is not A's"); - assert_eq!(m[2], (Margins::uniform(36.0), 1, true)); - // Back to A: page 2 follows A again. - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "A"})).unwrap(); - assert_eq!(margins(&s.doc().unwrap().doc)[1], (Margins::uniform(20.0), 1, false)); - } - - #[test] - fn parent_guides_show_on_pages_but_stay_on_the_parent() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - // A vertical guide 100 pt into A's right page (x = 612 in the parent spread). - s.execute("guide.add", &json!({"spread": {"kind": "parent", "index": 0}, "page": 1, "orientation": "vertical", "position": 712})).unwrap(); - let d = &s.doc().unwrap().doc; - let right = d.page(0).unwrap(); - let shown: Vec = d.parent_guides(right).map(|g| g.position).collect(); - assert_eq!(shown, vec![100.0], "moved to the document page's place"); - assert_eq!(d.parent_guides(d.page(1).unwrap()).count(), 0, "the left page shows A's left page's guides"); - // Not the document page's own: not listed, moved or deleted from there. - assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap(), json!([])); - assert!(s.execute("guide.move", &json!({"spread": 0, "page": 0, "index": 0, "position": 50})).is_err()); - assert!(s.execute("guide.delete", &json!({"spread": 0, "page": 0, "index": 0})).is_err()); - } -} - -#[cfg(test)] -mod parent_tests { - use serde_json::json; - - fn parent_of(s: &crate::Session, page: usize) -> Option { - s.doc().unwrap().doc.page(page).unwrap().parent.map(|p| p.0) - } - - #[test] - fn parents_with_one_prefix_stay_distinct() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - let a = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Parent"})).unwrap()["id"].as_u64().unwrap(); - let b = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Half"})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); - assert_eq!(parent_of(&s, 0), Some(b)); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Half"})).unwrap(); - assert_eq!(parent_of(&s, 1), Some(b)); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Parent"})).unwrap(); - assert_eq!(parent_of(&s, 1), Some(a)); - // A prefix two parents share, or a name no parent has, is an error and changes nothing. - assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG"})).is_err()); - assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "Nope"})).is_err()); - assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": 999_999})).is_err()); - assert_eq!(parent_of(&s, 1), Some(a)); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "[None]"})).unwrap(); - assert_eq!(parent_of(&s, 1), None); - } - - #[test] - fn edit_parents_shows_the_named_parent() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let ids: Vec = - ["B", "C", "D"].iter().map(|p| s.execute("layout.parents.new", &json!({"prefix": p})).unwrap()["id"].as_u64().unwrap()).collect(); - s.ui_requests.clear(); - let r = s.execute("layout.parents.edit", &json!({"parent": ids[2]})).unwrap(); - assert_eq!(r, json!({"on": true, "parent": ids[2]})); - assert!(s.doc().unwrap().editing_parents); - let n = s.doc().unwrap().doc.parent_index(designcraft_doc::SpreadId(ids[2])).unwrap(); - let shown: Vec<_> = s - .ui_requests - .iter() - .filter_map(|r| match r { - crate::UiRequest::View { params } => params.get("showSpread").cloned(), - _ => None, - }) - .collect(); - assert_eq!(shown, vec![json!({"kind": "parent", "index": n})]); - let r = s.execute("layout.parents.edit", &json!({"on": true, "parent": "C-Parent"})).unwrap(); - assert_eq!(r["parent"], ids[1]); - assert!(s.execute("layout.parents.edit", &json!({"parent": "Z-Parent"})).is_err()); - let r = s.execute("layout.parents.edit", &json!({"on": false})).unwrap(); - 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![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![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}"); - } - - fn columns_of(s: &crate::Session, abs: usize) -> (Option>, bool) { - let pg = s.doc().unwrap().doc.page(abs).unwrap().clone(); - (pg.columns.positions, pg.local_margins) - } - - /// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked. - fn three_columns(pages: u64) -> crate::Session { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": pages})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap(); - s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); - s - } - - /// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring - /// gutter and the margin, makes a following page's columns its own, and undoes in one step. - #[test] - fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() { - let mut s = three_columns(1); - assert_eq!(columns_of(&s, 0), (None, false), "the page follows A"); - let mv = - |s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x})); - let r = mv(&mut s, 0, 236.0).unwrap(); - assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0})); - assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); - let inspect = s.execute("document.inspect", &json!({})).unwrap(); - assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0])); - // Past the next gutter: it stops there, and the middle column has no width. - assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0)); - // Past the right margin: the last gutter stops 12 pt (its width) inside it. - assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0])); - // Past the left margin. - assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0])); - for _ in 0..3 { - s.execute("edit.undo", &json!({})).unwrap(); - } - assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag"); - } - - /// On a parent page the drag changes the parent, and the document pages that follow it get - /// the new columns; a page with columns of its own keeps them. - #[test] - fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() { - let mut s = three_columns(3); - // Page 1, the left page of spread 1, gets columns of its own. - let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0; - s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap(); - let own = columns_of(&s, 1); - assert!(own.1 && own.0.is_some()); - let d = &s.doc().unwrap().doc; - // A's right page: the one page 0 (a right page) and page 2 follow. - let x0 = d.parents[0].pages[1].margin_rect().x0; - s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap(); - let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0]; - assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone())); - assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false)); - assert_eq!(columns_of(&s, 2), (Some(moved), false)); - assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own"); - // The same parent page by spread reference. - let spread = json!({"kind": "parent", "index": 0}); - s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap(); - assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0); - } - - /// A drag's preview in one document is put back there when another document became active. - #[test] - fn a_column_guide_preview_is_cancelled_in_its_own_document() { - let mut s = three_columns(1); - let uid = s.doc().unwrap().uid; - s.begin_interaction("Move Column Guide").unwrap(); - s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap(); - s.execute("file.new", &json!({})).unwrap(); - s.cancel_interaction_of(uid); - s.set_active(0); - assert!(s.doc().unwrap().interaction.is_none()); - assert_eq!(columns_of(&s, 0), (None, false)); - } - - #[test] - fn move_column_guide_refuses_while_locked_and_hostile_params() { - let mut s = three_columns(1); - s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap(); - assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err()); - assert_eq!(columns_of(&s, 0), (None, false)); - s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); - for p in [ - json!({"page": 0, "gutter": 0}), - json!({"page": 0, "gutter": 0, "position": f64::NAN}), - json!({"page": 0, "gutter": 0, "position": "200"}), - json!({"page": 0, "gutter": 2, "position": 200}), - json!({"page": 0, "gutter": u64::MAX, "position": 200}), - json!({"page": 0, "gutter": -1, "position": 200}), - json!({"page": 0, "gutter": 0.5, "position": 200}), - json!({"page": 9, "gutter": 0, "position": 200}), - json!({"page": u64::MAX, "gutter": 0, "position": 200}), - json!({"gutter": 0, "position": 200}), - json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}), - json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}), - json!({"page": 0, "gutter": 0, "position": f64::MAX}), - ] { - let r = s.execute("layout.moveColumnGuide", &p); - // A huge but finite position is clamped to the margin; everything else is refused. - assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}"); - } - } -} - -#[cfg(test)] -mod parent_base_tests { - use serde_json::{Value, json}; - - fn rect(s: &mut crate::Session, spread: Value, r: [f64; 4], swatch: &str) -> u64 { - let id = s.execute("frame.create", &json!({"spread": spread, "rect": r, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.fill", &json!({"ids": [id], "swatch": swatch})).unwrap(); - id - } - - fn page(s: &crate::Session, abs: usize) -> designcraft_render::Rendered { - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - rr.render_page(&s.doc().unwrap().doc, &s.cache, abs, 1.0, false, &Default::default()).unwrap() - } - - /// Parent A (black), parent B based on A (cyan, half over A's square), and a page item (paper) - /// over B's: the page draws A, then B, then its own items. - #[test] - fn based_on_parent_draws_base_items_under_parent_items() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Opening"})).unwrap(); - rect(&mut s, json!({"kind": "parent", "index": 0}), [0.0, 0.0, 200.0, 200.0], "[Black]"); - rect(&mut s, json!({"kind": "parent", "index": 1}), [100.0, 0.0, 300.0, 200.0], "C=100 M=0 Y=0 K=0"); - rect(&mut s, json!(1), [150.0, 0.0, 190.0, 40.0], "[Paper]"); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); - let black = |p: [u8; 4]| p[0] < 80 && p[1] < 80 && p[2] < 80; - let cyan = |p: [u8; 4]| p[0] < 80 && p[1] > 120 && p[2] > 180; - let paper = |p: [u8; 4]| p[0] > 240 && p[1] > 240 && p[2] > 240; - assert!(paper(page(&s, 1).pixel(50, 100)), "B alone does not show A's items"); - - let r = s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap(); - assert_eq!(r, json!({"parent": "B-Opening", "basedOn": "A-Parent"})); - let img = page(&s, 1); - assert!(black(img.pixel(50, 100)), "A's item: {:?}", img.pixel(50, 100)); - assert!(cyan(img.pixel(150, 150)), "B's item over A's: {:?}", img.pixel(150, 150)); - assert!(paper(img.pixel(170, 20)), "the page's item over B's: {:?}", img.pixel(170, 20)); - assert!(black(page(&s, 0).pixel(50, 100)) && !cyan(page(&s, 0).pixel(250, 100)), "page 1 uses A only"); - // Parent B itself shows A's items under its own. - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - let placed = [designcraft_render::Placed { spread: designcraft_doc::SpreadRef::Parent(1), xf: designcraft_geom::Affine::IDENTITY }]; - let opts = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let img = rr.render(&s.doc().unwrap().doc, &s.cache, &placed, 400, 400, designcraft_geom::Affine::IDENTITY, &opts); - assert!(black(img.pixel(50, 100)) && cyan(img.pixel(150, 150)), "{:?} {:?}", img.pixel(50, 100), img.pixel(150, 150)); - let inspect = s.execute("document.inspect", &json!({})).unwrap(); - assert_eq!(inspect["parents"][1]["basedOn"], "A-Parent"); - assert_eq!(inspect["parents"][0]["basedOn"], Value::Null); - - // Cycles are refused. - assert!(s.execute("layout.parents.options", &json!({"parent": "A", "basedOn": "B"})).is_err()); - assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "B-Opening"})).is_err()); - assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "Z"})).is_err()); - - s.execute("edit.undo", &json!({})).unwrap(); - assert!(paper(page(&s, 1).pixel(50, 100)), "undo clears Based on Parent"); - s.execute("edit.redo", &json!({})).unwrap(); - s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": null})).unwrap(); - assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["parents"][1]["basedOn"], Value::Null); - } - - /// A's page-number marker shown on parent XYZ (based on A) reads XYZ, as markers on XYZ's own items do. - #[test] - fn base_parent_markers_read_the_showing_parent() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "XYZ"})).unwrap(); - s.execute("layout.parents.options", &json!({"parent": "XYZ", "basedOn": "A"})).unwrap(); - let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let sid = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let glyphs = |on| -> usize { - let cs = s.cache.get_on(&d, sid, on); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(glyphs(designcraft_compose::ShownOn::Own), 1, "A"); - assert_eq!(glyphs(designcraft_compose::ShownOn::Parent(1)), 3, "XYZ"); - } - - /// A file whose parents are based on each other opens, with the cycle broken and reported. - #[test] - fn opening_a_parent_cycle_breaks_it_with_a_warning() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); - let mut d = s.doc().unwrap().doc.as_ref().clone(); - let (a, b) = (d.parents[0].id, d.parents[1].id); - std::sync::Arc::make_mut(&mut d.parents[0]).parent.as_mut().unwrap().based_on = Some(b); - std::sync::Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a); - let b64 = crate::cmd::base64_encode(&crate::cmd::to_bytes(&d).unwrap()); - let r = s.execute("file.openBytes", &json!({"name": "cycle", "base64": b64})).unwrap(); - assert!(r["warnings"].to_string().contains("cycle"), "{r}"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None); - assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(a)); - } -} +pub(crate) use pages::resize_pages; +pub use registry::specs; +pub use sections::chapter_numbering; #[cfg(test)] #[path = "parent_delete_tests.rs"] mod parent_delete_tests; - -#[cfg(test)] -mod insert_count_tests { - use serde_json::json; - - use crate::Session; - - /// A count that is given but is not a number of pages used to insert one page anyway: - /// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped - /// to 9999. A position past the last page appends, which is deliberate. - #[test] - fn a_page_count_insert_pages_cannot_mean_is_refused() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); - let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap(); - assert_eq!(total(&mut s), 3, "one page added"); - - for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] { - let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string(); - assert!(e.contains("1 to 9999 pages"), "{params}: {e}"); - } - // Nothing was inserted by any of those. - assert_eq!(s.doc().unwrap().doc.page_count(), 3); - // A position past the last page still appends, which `insert_pages_bounds.rs` pins. - let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap(); - assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4))); - // The positions it does have still work: after the first page of three. - let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap(); - assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6))); - } -} diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs new file mode 100644 index 0000000000..52fa0ea714 --- /dev/null +++ b/crates/engine/src/cmd/layout/layers.rs @@ -0,0 +1,137 @@ +use super::*; + +/// Layer Options: every field in one undo step; a bad name or colour changes nothing. +pub(super) fn set_layer(s: &mut Session, p: &Value) -> Result { + const ID: &str = "layer.set"; + let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); + let name = match p.get("name") { + Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "a layer needs a name"))?.to_string()), + None => None, + }; + let color: Option<[u8; 3]> = match p.get("color") { + None => None, + Some(Value::String(n)) => Some( + LAYER_COLORS + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(n)) + .map(|(_, c)| *c) + .ok_or_else(|| bad(ID, format!("unknown layer colour `{n}`")))?, + ), + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(ID, "color is [r,g,b] (0–255) or a layer colour name"))?), + }; + let flag = |k: &str| match p.get(k) { + None => Ok(None), + Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(ID, format!("`{k}` is true or false"))), + }; + let (visible, locked, printable) = (flag("visible")?, flag("locked")?, flag("printable")?); + let (guides, wrap) = (flag("showGuides")?, flag("suppressWrapWhenHidden")?); + s.edit(|d, _| { + if let Some(n) = &name + && d.layers.iter().any(|l| l.id != id && l.name == *n) + { + return Err(bad(ID, format!("a layer named `{n}` exists"))); + } + let l = d.layer_mut(id).ok_or_else(|| bad(ID, "no such layer"))?; + if let Some(n) = name { + l.name = n; + } + if let Some(c) = color { + l.color = c; + } + for (slot, v) in [ + (&mut l.visible, visible), + (&mut l.locked, locked), + (&mut l.printable, printable), + (&mut l.show_guides, guides), + (&mut l.suppress_wrap_when_hidden, wrap), + ] { + if let Some(v) = v { + *slot = v; + } + } + ok() + }) +} + +#[cfg(test)] +mod layer_tests { + use serde_json::json; + + #[test] + fn layer_move_reorders_and_rejects_bad_input() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let order = |s: &crate::Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::>(); + assert_eq!(order(&s), [l2, l1]); + for bad in [json!({"id": l1, "to": 2}), json!({"id": 999, "to": 0}), json!({"id": l1}), json!({"to": 0})] { + assert!(s.execute("layer.move", &bad).is_err(), "{bad}"); + assert_eq!(order(&s), [l2, l1]); + } + s.execute("layer.move", &json!({"id": l1, "to": 0})).unwrap(); + assert_eq!(order(&s), [l1, l2]); + } + + #[test] + fn merge_delete_unused_hide_and_lock_others() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + let l3 = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.activate", &json!({"id": l2})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.others", &json!({"id": l2, "hide": true, "lock": true})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.layers.iter().all(|l| (l.id.0 == l2) == l.visible && (l.id.0 != l2) == l.locked)); + s.execute("layer.others", &json!({"id": l2, "show": true, "unlock": true})).unwrap(); + assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.visible && !l.locked)); + let r = s.execute("layer.deleteUnused", &json!({})).unwrap(); + assert_eq!(r["deleted"], 2, "the empty default layer and Empty"); + let _ = (l1, l3); + let l4 = s.execute("layer.new", &json!({"name": "Into"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.merge", &json!({"ids": [l4, l2]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.len(), 1); + assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4); + } +} + +#[cfg(test)] +mod layer_options_tests { + use serde_json::json; + + #[test] + fn layer_set_sets_every_field_and_rejects_bad_names() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let first = s.doc().unwrap().doc.layers[0].id.0; + let id = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + let all = json!({"id": id, "name": " Artwork ", "color": "Gold", "visible": false, "locked": true, "printable": false, + "showGuides": false, "suppressWrapWhenHidden": true}); + s.execute("layer.set", &all).unwrap(); + let l = s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).cloned().unwrap(); + assert_eq!(l.name, "Artwork"); + assert_eq!(l.color, [255, 153, 0]); + assert!(!l.visible && l.locked && !l.printable && !l.show_guides && l.suppress_wrap_when_hidden); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).unwrap().name, "Art", "one undo step"); + let first_name = s.doc().unwrap().doc.layers.iter().find(|l| l.id.0 == first).unwrap().name.clone(); + let before = s.doc().unwrap().doc.layers.clone(); + for p in [ + json!({"id": id, "name": ""}), + json!({"id": id, "name": " "}), + json!({"id": id, "name": first_name}), + json!({"id": id, "name": "Fine", "color": "Plaid"}), + json!({"id": id, "color": [300, 0, 0]}), + json!({"id": id, "visible": "yes"}), + json!({"id": 999_999, "name": "Ghost"}), + ] { + assert!(s.execute("layer.set", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().doc.layers, before); + // Keeping its own name is fine. + s.execute("layer.set", &json!({"id": id, "name": "Art", "color": [1, 2, 3]})).unwrap(); + } +} diff --git a/crates/engine/src/cmd/layout/margins.rs b/crates/engine/src/cmd/layout/margins.rs new file mode 100644 index 0000000000..41d30557bb --- /dev/null +++ b/crates/engine/src/cmd/layout/margins.rs @@ -0,0 +1,423 @@ +use super::parents::parent_spread_index; +use super::*; + +/// Each page's margin box, by spread and page id. +pub(super) fn margin_boxes(d: &designcraft_doc::Document) -> Vec<(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)> { + d.spread_refs() + .filter_map(|r| d.spread(r).map(|sp| (r, sp))) + .flat_map(|(r, sp)| sp.pages.iter().map(move |pg| (r, pg.id, pg.margin_rect()))) + .collect() +} + +/// Layout Adjustment: objects on each page move and resize from its old margin box to its new +/// one (content keeps its size; frames that auto-fit refit). +pub(super) fn adjust_layout( + d: &mut designcraft_doc::Document, + before: &[(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)], +) { + use designcraft_geom::Affine; + let after = margin_boxes(d); + for (r, pid, old) in before { + let Some((_, _, new)) = after.iter().find(|(r2, p2, _)| r2 == r && p2 == pid) else { continue }; + if old == new || old.width() < 1e-6 || old.height() < 1e-6 { + continue; + } + let m = Affine::translate((new.x0, new.y0)) + * Affine::scale_non_uniform(new.width() / old.width(), new.height() / old.height()) + * Affine::translate((-old.x0, -old.y0)); + let Some(sp) = d.spread(*r) else { continue }; + // The page's objects (by centre, measured against the old boxes). + let ids: Vec = sp + .items + .iter() + .filter(|it| { + let c = it.bounds().center(); + // Pages share a spread side by side: the margin box's page is the one under the centre's x. + before + .iter() + .filter(|(r2, _, _)| r2 == r) + .min_by(|a, b| dist(a.2, c.x).total_cmp(&dist(b.2, c.x))) + .is_some_and(|(_, p2, _)| p2 == pid) + }) + .map(|it| it.id) + .collect(); + for id in ids { + let Some(it) = d.item_mut(id) else { continue }; + if matches!(it.content, designcraft_doc::Content::Group { .. }) { + it.xf = m * it.xf; + } else { + let inner = it.xf.inverse() * m * it.xf; + it.path.transform(inner); + if let Some(w) = &mut it.wrap.path { + w.transform(inner); + } + let ib = it.inner_bounds(); + if let designcraft_doc::Content::Graphic(g) = &mut it.content { + // Content keeps its size but moves with the frame; auto-fit refits. + let o = inner * designcraft_geom::Point::ZERO; + g.xf = Affine::translate(o.to_vec2()) * g.xf; + if let Some(xf) = g.fitted(ib, g.auto_fit) { + g.xf = xf; + } + } + } + } + } +} + +/// Horizontal distance from `x` to a margin box (0 inside). +fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { + if x < r.x0 { + r.x0 - x + } else if x > r.x1 { + x - r.x1 + } else { + 0.0 + } +} + +/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page. +pub(super) 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(_)) => { + if let Ok(m) = serde_json::from_value(v.clone()) { + pg.margins = m; + } + } + _ => {} + } + 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) { + 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).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. +pub(super) 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; + +/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column +/// Guides is off. +pub(super) fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + match s.active() { + Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()), + _ => Ok(()), + } +} + +/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`. +pub(super) fn move_column_guide(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layout.moveColumnGuide"; + let index = |key: &str| -> Result { + let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?; + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer"))) + }; + let (page, gutter) = (index("page")?, index("gutter")?); + let position = + p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?; + let r = match p.get("parent") { + None | Some(Value::Null) => crate::cmd::spread_param(p, CMD)?, + Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?), + }; + s.edit(|d, _| { + if d.settings.column_guides_locked { + return Err(bad(CMD, "column guides are locked")); + } + let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?; + let m = pg.margin_rect(); + let count = pg.columns.count; + if gutter.checked_add(1).is_none_or(|g| g >= count as usize) { + return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1)))); + } + let positions = + pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?; + let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v); + pg.columns.positions = Some(positions.clone()); + match r { + designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true, + designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(), + } + Ok(json!({"positions": positions, "left": left})) + }) +} + +#[cfg(test)] +mod adjust_tests { + use designcraft_doc::Margins; + use serde_json::json; + + use crate::Session; + + #[test] + fn adjust_layout_follows_the_new_page_size_and_margins() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + // Letter, 36 pt margins: a frame filling the left half of the margin box. + let id = s.execute("frame.create", &json!({"rect": [36, 36, 306, 756]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.documentSetup", &json!({"width": 1224, "adjustLayout": true})).unwrap(); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert!((b.x0 - 36.0).abs() < 1e-6 && (b.x1 - 612.0).abs() < 1e-6, "half of the wider margin box: {b:?}"); + s.execute("layout.marginsAndColumns", &json!({"margins": 72, "adjustLayout": true})).unwrap(); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert!((b.x0 - 72.0).abs() < 1e-6 && (b.y0 - 72.0).abs() < 1e-6 && (b.y1 - 720.0).abs() < 1e-6, "{b:?}"); + // Without the option objects stay put. + s.execute("layout.marginsAndColumns", &json!({"margins": 36})).unwrap(); + let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert_eq!(b, b2); + } + + #[test] + fn parent_margins_change_the_parent_and_follow_unoverridden_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + // Page 3 has its own margins (an override); pages 1 and 2 still follow the parent. + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); + let parent_before = s.doc().unwrap().doc.parents[0].pages[0].margins; + assert_eq!(parent_before, Margins::uniform(36.0)); + s.execute("layout.marginsAndColumns", &json!({"parent": 0, "margins": 60, "columns": 2})).unwrap(); + let d = &s.doc().unwrap().doc; + // The parent page itself changed (this is what was drawn on screen). + assert_eq!(d.parents[0].pages[0].margins, Margins::uniform(60.0)); + assert_eq!(d.parents[0].pages[0].columns.count, 2); + // Pages without their own margins follow; the page given its own keeps them. + assert_eq!(d.page(0).unwrap().margins, Margins::uniform(60.0)); + assert_eq!(d.page(1).unwrap().margins, Margins::uniform(60.0)); + assert_eq!(d.page(2).unwrap().margins, Margins::uniform(100.0)); + // 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>, width: f64) { + let want: Vec = 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)); + } +} diff --git a/crates/engine/src/cmd/layout/pages.rs b/crates/engine/src/cmd/layout/pages.rs new file mode 100644 index 0000000000..d5d01423ad --- /dev/null +++ b/crates/engine/src/cmd/layout/pages.rs @@ -0,0 +1,535 @@ +use super::margins::set_page_width; +use super::*; + +#[allow(dead_code)] +fn _r(_: Result<()>) {} + +/// Layout › Page Size (the Page tool): resize pages one by one. Left pages grow to the left +/// (the spine stays put); objects move with their page. +pub(super) fn page_size(s: &mut crate::Session, p: &Value) -> Result { + let pages: Vec = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageSize", "give `pages` (1-based)")); + } + let (mut w, mut h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); + if let Some(name) = str_param(p, "preset") { + let pr = designcraft_doc::build::PRESETS + .iter() + .find(|x| x.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("layout.pageSize", format!("no preset `{name}`")))?; + w = Some(pr.width); + h = Some(pr.height); + } + let anchor = match p.get("anchor") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, + }; + s.edit(|d, _| { + resize_pages_from(d, &pages, w, h, anchor); + Ok(json!({"pages": pages.len()})) + }) +} + +pub(super) fn page_options(s: &mut crate::Session, p: &Value) -> Result { + let pages: Vec = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageOptions", "give `pages` (1-based)")); + } + let overlay = p.get("showParentOverlay").and_then(Value::as_bool); + s.edit(|d, _| { + for page in &pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + if let Some(v) = overlay { + pg.show_parent_overlay = v; + } + } + Ok(json!({"pages": pages.len()})) + }) +} + +/// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule +/// re-flow their objects by it. +pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { + resize_pages_from(d, pages, w, h, 0); +} + +/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: +/// the page grows away from it, so objects keep their place relative to it. +pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); + { + for page in pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); + let old: Vec = sp.pages.iter().map(|x| x.x).collect(); + let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let last = sp.pages.len() - 1; + let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); + let pg = &mut sp.pages[pi]; + 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 { + set_page_width(pg, w.max(1.0)); + } + if let Some(h) = h { + pg.height = h.max(1.0); + } + let left = pg.side == designcraft_doc::PageSide::Left; + sp.relayout(); + // Keep the spine: shift the whole spread back when a left page grows. + if left && dw != 0.0 { + for q in &mut sp.pages { + q.x -= dw; + } + } + let new: Vec = sp.pages.iter().map(|x| x.x).collect(); + // Objects follow their page. + let shifts: Vec<(f64, f64, f64)> = + sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); + for it in &mut sp.items { + let cx = it.bounds().center().x; + if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) + && *dx != 0.0 + { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; + } + } + // Page guides move with their page. + for (k, q) in sp.pages.iter_mut().enumerate() { + let dx = new[k] - old[k]; + if dx != 0.0 { + for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + } + } + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; + crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); + } + } +} + +/// File › Document Setup values. +pub(super) fn document_setup(d: &designcraft_doc::Document) -> Value { + let st = &d.settings; + let start = d.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).unwrap_or(1); + json!({"width": st.page_width, "height": st.page_height, "pages": d.page_count(), "startPage": start, "facingPages": st.facing_pages, + "binding": if st.right_to_left_binding { "rightToLeft" } else { "leftToRight" }, "intent": st.intent, "bleed": st.bleed, "slug": st.slug, + "margins": d.page(0).map(|p| p.margins), "primaryTextFrame": st.primary_text_frame}) +} + +#[cfg(test)] +mod setup_tests { + use serde_json::json; + + #[test] + fn chapter_numbering_sets_style_and_start() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("layout.chapterNumbering", &json!({})).unwrap(); + assert_eq!(r, json!({"chapterNumber": 1, "label": "1", "style": "arabic", "source": "automatic", "book": null})); + let r = s.execute("layout.chapterNumbering", &json!({"start": 4, "style": "upperRoman"})).unwrap(); + assert_eq!((&r["label"], &r["source"]), (&json!("IV"), &json!("userDefined")), "a start number means Start Chapter Numbering at"); + s.execute("variables.define", &json!({"name": "Chapter", "type": "chapterNumber"})).unwrap(); + let d = &s.doc().unwrap().doc; + let chapter = d.variable("Chapter").unwrap(); + assert_eq!(d.variable_value(chapter, Some(0)).as_deref(), Some("IV"), "the Chapter Number variable uses the style"); + for bad in [json!({"style": "symbols"}), json!({"start": 0}), json!({"start": 100_000}), json!({"source": "nextBook"})] { + assert!(s.execute("layout.chapterNumbering", &bad).is_err(), "{bad}"); + } + s.execute("layout.chapterNumbering", &json!({"source": "sameAsPrevious"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("layout.chapterNumbering", &json!({})).unwrap()["source"], "userDefined", "undoable"); + } + + #[test] + fn document_setup_sets_every_pages_margins_in_one_step() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 3, "margins": 36})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text"})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({})).unwrap(); + assert_eq!(r["margins"], json!({"top": 36.0, "bottom": 36.0, "inside": 36.0, "outside": 36.0})); + let m = json!({"top": 72, "bottom": 54, "inside": 72, "outside": 72}); + let r = s.execute("layout.documentSetup", &json!({"margins": m, "bleed": 9, "adjustLayout": true})).unwrap(); + assert_eq!(r["margins"]["bottom"], 54.0); + let d = &s.doc().unwrap().doc; + assert!( + d.spreads.iter().chain(&d.parents).flat_map(|sp| &sp.pages).all(|pg| pg.margins.top == 72.0 && pg.margins.bottom == 54.0), + "every page and parent page" + ); + let b = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap().bounds(); + assert!((b.y0 - 72.0).abs() < 1e-6, "Adjust Layout: the frame follows the margins ({b:?})"); + // One step: undo brings back the margins and the bleed. + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({})).unwrap(); + assert_eq!((r["margins"]["top"].clone(), r["bleed"][0].clone()), (json!(36.0), json!(0.0))); + for bad in [ + json!({"width": 0}), + json!({"height": -5}), + json!({"width": 1e9}), + json!({"width": "wide"}), + json!({"margins": -1}), + json!({"margins": {"top": 1}}), + ] { + assert!(s.execute("layout.documentSetup", &bad).is_err(), "{bad}"); + } + assert_eq!(s.execute("layout.documentSetup", &json!({})).unwrap()["width"], 612.0, "refused values change nothing"); + } + + #[test] + fn document_setup_pages_start_bleed_slug() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({})).unwrap(); + assert_eq!(r["pages"], 2); + assert_eq!(r["startPage"], 1); + let r = s.execute("layout.documentSetup", &json!({"pages": 5, "startPage": 2, "bleed": 9, "slug": [0, 18, 0, 0], "intent": "web"})).unwrap(); + assert_eq!(r["pages"], 5); + assert_eq!(r["startPage"], 2); + assert_eq!(r["bleed"], json!([9.0, 9.0, 9.0, 9.0])); + assert_eq!(r["slug"], json!([0.0, 18.0, 0.0, 0.0])); + assert_eq!(r["intent"], "web"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_name(0), "2"); + assert_eq!(d.page(0).unwrap().side, designcraft_doc::PageSide::Left); + assert_eq!(d.spreads[0].pages.len(), 2, "pages 2–3 form the first spread"); + let r = s.execute("layout.documentSetup", &json!({"pages": 3})).unwrap(); + assert_eq!(r["pages"], 3); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn right_to_left_binding_reverses_spreads() { + use designcraft_doc::PageSide::{Left, Right}; + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + // A frame on page 2 stays on page 2. + let w = s.doc().unwrap().doc.settings.page_width; + let f = s.execute("frame.create", &json!({"rect": [w + 72.0, 72, w + 200.0, 200], "content": "text", "text": "two"})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); + assert_eq!(r["binding"], "rightToLeft"); + let d = &s.doc().unwrap().doc; + let shape: Vec> = d.spreads.iter().map(|sp| sp.pages.iter().map(|p| (p.side, p.x)).collect()).collect(); + // Page 1 alone on the left; pages 2–3 with 2 on the right; page 4 alone on the right. + assert_eq!(shape, vec![vec![(Left, 0.0)], vec![(Right, w), (Left, 0.0)], vec![(Right, 0.0)]]); + let (si, _) = d.page_loc(1).unwrap(); + assert_eq!(si, 1); + let it = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap(); + assert_eq!(d.spreads[si].page_at_x(it.bounds().center().x), Some(0), "the frame is still on page 2"); + assert!(it.bounds().x0 >= w, "page 2 is the right page now"); + d.check().unwrap(); + // And back. + s.execute("layout.documentSetup", &json!({"binding": "leftToRight"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]); + } + + /// Bound right to left, a parent spread lists its right page first; each document page still + /// shows the parent page on its own side, at its own position, also after an IDML round trip. + #[test] + fn right_to_left_binding_shows_the_parent_page_of_the_same_side() { + use designcraft_doc::PageSide::{Left, Right}; + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); + let w = s.doc().unwrap().doc.settings.page_width; + let parent = json!({"kind": "parent", "index": 0}); + for (rect, text) in [(json!([72, 36, 200, 60]), "left head"), (json!([w + 72.0, 36, w + 200.0, 60]), "right head")] { + s.execute("frame.create", &json!({"spread": parent, "rect": rect, "content": "text", "text": text})).unwrap(); + } + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.parents[0].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Right, w), (Left, 0.0)]); + let docs = [(*d).clone(), designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap()]; + for d in docs { + let mut s = crate::Session::new(); + s.add_document(crate::DocState::new(d, None)); + for (page, side, text) in [(0, Left, "left head"), (1, Right, "right head"), (2, Left, "left head"), (3, Right, "right head")] { + let r = s.execute("layout.overrideParentItems", &json!({"page": page})).unwrap(); + let d = &s.doc().unwrap().doc; + let ids = r["ids"].as_array().unwrap(); + assert_eq!(ids.len(), 1, "page {}", page + 1); + let it = d.item(designcraft_doc::ItemId(ids[0].as_u64().unwrap())).unwrap(); + let story = d.story(it.text_frame().unwrap().story).unwrap(); + assert_eq!((d.page(page).unwrap().side, story.text.trim_end()), (side, text), "page {}", page + 1); + assert_eq!(d.page_of_item(it.id), Some(page), "page {}", page + 1); + } + } + } + + /// Multi-column frames filled the left column first in a right-to-left document (#100): + /// stories made in a right-to-left-bound document start their columns on the right. + #[test] + fn new_stories_follow_the_binding_direction() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n"); + for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}"); + let cs = s.cache.get(&st.doc, sid, None); + let fr = &cs.frames[0]; + let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap()); + assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns); + assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column"); + // An empty frame given text content starts a story too. + let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap(); + s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap(); + let d = &s.doc().unwrap().doc; + let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content"); + } + } +} + +#[cfg(test)] +mod page_size_tests { + use serde_json::json; + + #[test] + fn page_size_per_page_keeps_objects_on_their_page() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + // Page 3 is the right page of spread 2 (pages 2–3); an object on it. + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(2).unwrap(); + let x0 = d.spreads[si].pages[pi].x; + let id = s.execute("frame.create", &json!({"spread": si, "rect": [x0 + 100.0, 100, x0 + 200.0, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 500})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let left = &d.spreads[si].pages[0]; + assert_eq!((left.width, left.height), (300.0, 500.0)); + assert_eq!(d.spreads[si].pages[1].x, x0, "the spine stays"); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.bounds().x0, x0 + 100.0); + assert_eq!(d.page_of_item(it.id), Some(2)); + s.execute("layout.pageSize", &json!({"pages": [3], "preset": "A4"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!((d.spreads[si].pages[1].width - 595.276).abs() < 0.01); + assert!(s.execute("layout.pageSize", &json!({"pages": [9], "width": 100})).is_err()); + } +} + +#[cfg(test)] +mod spread_tests { + use serde_json::json; + + #[test] + fn three_page_spread_survives_page_inserts() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 5})).unwrap(); + // [1] [2 3] [4 5]: page 4 joins spread 1 → [1] [2 3 4] [5]. + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(3).unwrap(); + let x = d.spreads[si].pages[pi].x; + let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 50.0, 50, x + 150.0, 150]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.pages.toSpread", &json!({"page": 4, "spread": 1})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.spreads[1].pages.len(), 3); + assert!(!d.spreads[1].allow_shuffle); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(id)), Some(3), "the object went with its page"); + // Adding a page at the start reshuffles the others but keeps the 3-page spread. + s.execute("layout.pages.insert", &json!({"after": 0, "count": 1})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.spreads.iter().any(|sp| sp.pages.len() == 3 && !sp.allow_shuffle)); + assert_eq!(d.page_count(), 6); + d.check().unwrap(); + } +} + +#[cfg(test)] +mod page_numbering_view_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn section_and_absolute_page_numbering_view() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute("layout.section", &json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["i", "ii", "1", "2"]); + assert_eq!(s.resolve_page("ii"), Some(1)); + assert_eq!(s.resolve_page("1"), Some(2), "section name first"); + assert_eq!(s.resolve_page("+1"), Some(0), "+n is a position"); + s.execute("prefs.set", &json!({"absolutePageNumbers": true})).unwrap(); + assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["1", "2", "3", "4"]); + assert_eq!(s.resolve_page("1"), Some(0)); + assert_eq!(s.resolve_page("9"), None); + } + + #[test] + fn section_start_numbers_are_bounded() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + for n in [json!(0), json!(100_000), json!(u32::MAX), json!(u64::from(u32::MAX) + 2), json!(-1), json!(2.5), json!("7")] { + let e = s.execute("layout.section", &json!({"page": 1, "startNumber": n})).unwrap_err().to_string(); + assert!(e.contains("startNumber"), "{n}: {e}"); + assert!(s.execute("layout.documentSetup", &json!({"startPage": n})).is_err(), "{n}"); + } + assert_eq!((0..2).map(|i| s.page_label(i)).collect::>(), ["1", "2"]); + let r = s.execute("layout.section", &json!({"page": 1, "startNumber": 99_999})).unwrap(); + assert_eq!(r["names"], json!(["99999", "100000"])); + } + + #[test] + fn numbering_options_apply_in_one_undo_step_or_not_at_all() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let names = |s: &crate::Session| (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect::>(); + let r = s + .execute( + "layout.numberingOptions", + &json!({"page": 3, "startNumber": 5, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "chapter": {"start": 4, "style": "upperRoman"}}), + ) + .unwrap(); + assert_eq!((r["names"].clone(), r["chapter"].clone()), (json!(["1", "2", "A-v", "A-vi"]), json!("IV"))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&s), ["1", "2", "3", "4"], "one undo reverts the section…"); + assert_eq!(s.doc().unwrap().doc.chapter_label(), "1", "…and the chapter numbering"); + // A refused part leaves the other undone too. + for bad in [ + json!({"page": 3, "startNumber": 5, "chapter": {"start": 100_000}}), + json!({"page": 3, "startNumber": 4_294_967_295u64, "chapter": {"start": 2}}), + json!({"page": 3, "prefix": "A+B", "chapter": {"start": 2}}), + json!({"page": 9, "chapter": {"start": 2}}), + ] { + assert!(s.execute("layout.numberingOptions", &bad).is_err(), "{bad}"); + assert_eq!(names(&s), ["1", "2", "3", "4"], "{bad}"); + assert_eq!(s.doc().unwrap().doc.settings.chapter_source, designcraft_doc::ChapterSource::Automatic, "{bad}"); + } + // Removing a section keeps the chapter part. + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + s.execute("layout.numberingOptions", &json!({"page": 3, "remove": true, "chapter": {"source": "userDefined", "start": 2}})).unwrap(); + assert_eq!(names(&s), ["1", "2", "3", "4"]); + assert_eq!(s.doc().unwrap().doc.chapter_label(), "2"); + } + + #[test] + fn section_prefixes_are_short_and_avoid_plus_and_comma() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + for prefix in ["A-", "Appendix", "§ 1"] { + s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).unwrap(); + } + for prefix in ["Appendix1", "A+", "1,2"] { + assert!(s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).is_err(), "{prefix}"); + } + assert!(s.execute("layout.section", &json!({"page": 2, "style": "symbols"})).is_err()); + assert_eq!(s.doc().unwrap().doc.sections.iter().find(|x| x.start == 1).unwrap().prefix, "§ 1", "a refused prefix changes nothing"); + } + + /// `layout.section` edits one named setting; the others stay as the section was (#717). + #[test] + fn updating_a_section_keeps_settings_the_command_does_not_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute( + "layout.section", + &json!({"page": 2, "startNumber": 1, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "marker": "First"}), + ) + .unwrap(); + assert_eq!(s.page_label(1), "A-i"); + s.execute("layout.section", &json!({"page": 2, "marker": "Second"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sec = d.sections.iter().find(|x| x.start == 1).expect("the page-2 section is still the only one there"); + assert_eq!(sec.marker, "Second"); + assert_eq!(sec.start_number, Some(1), "startNumber is kept"); + assert_eq!(sec.style, designcraft_doc::NumberStyle::LowerRoman, "style is kept"); + assert_eq!(sec.prefix, "A-", "prefix is kept"); + assert!(sec.include_prefix, "includePrefix is kept"); + assert_eq!(s.page_label(1), "A-i"); + } +} + +#[cfg(test)] +mod spread_rotation_input_tests { + use serde_json::json; + + #[test] + fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation; + for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] { + assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}"); + assert_eq!(rotation(&s), 0, "invalid input must not change the view"); + } + s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap(); + assert_eq!(rotation(&s), 1); + s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap(); + assert_eq!(rotation(&s), 0); + s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap(); + assert_eq!(rotation(&s), 2); + s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap(); + assert_eq!(rotation(&s), 0, "zero resets the spread view"); + } +} + +#[cfg(test)] +mod insert_count_tests { + use serde_json::json; + + use crate::Session; + + /// A count that is given but is not a number of pages used to insert one page anyway: + /// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped + /// to 9999. A position past the last page appends, which is deliberate. + #[test] + fn a_page_count_insert_pages_cannot_mean_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap(); + assert_eq!(total(&mut s), 3, "one page added"); + + for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] { + let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string(); + assert!(e.contains("1 to 9999 pages"), "{params}: {e}"); + } + // Nothing was inserted by any of those. + assert_eq!(s.doc().unwrap().doc.page_count(), 3); + // A position past the last page still appends, which `insert_pages_bounds.rs` pins. + let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap(); + assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4))); + // The positions it does have still work: after the first page of three. + let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap(); + assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6))); + } +} diff --git a/crates/engine/src/cmd/layout/parents.rs b/crates/engine/src/cmd/layout/parents.rs new file mode 100644 index 0000000000..4e464d5af0 --- /dev/null +++ b/crates/engine/src/cmd/layout/parents.rs @@ -0,0 +1,458 @@ +use super::*; + +/// A parent spread named by prefix, label (`A-Parent`) or index. +pub(super) fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option { + match v { + Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < d.parents.len()), + Value::String(s) => d.parents.iter().position(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)), + _ => None, + } +} + +/// A parent named by a command parameter: its id (a number), its full label (`"A-Parent"`), or +/// its prefix (`"A"`) when only one parent has it. `null` or `"[None]"` → `Some(None)` (no parent); +/// missing → `None` (the command's default). Unknown or ambiguous names are errors, so a typo never +/// clears a page's parent. +pub(super) fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Result>> { + let Some(v) = v else { return Ok(None) }; + match v { + Value::Null => Ok(Some(None)), + Value::String(s) if s == "[None]" => Ok(Some(None)), + Value::Number(n) => { + let id = n.as_u64().ok_or_else(|| bad(cmd, format!("no parent with id {n}")))?; + d.parents.iter().find(|p| p.id.0 == id).map(|p| Some(Some(p.id))).ok_or_else(|| bad(cmd, format!("no parent with id {id}"))) + } + Value::String(s) => { + let by = |f: &dyn Fn(&designcraft_doc::ParentInfo) -> bool| -> Vec { + d.parents.iter().filter(|p| p.parent.as_ref().is_some_and(f)).map(|p| p.id).collect() + }; + let mut hits = by(&|i| i.label() == *s); + if hits.is_empty() { + hits = by(&|i| i.prefix == *s); + } + match hits.as_slice() { + [id] => Ok(Some(Some(*id))), + [] => Err(bad(cmd, format!("no parent named `{s}`"))), + _ => Err(bad(cmd, format!("more than one parent is named `{s}`: give its id"))), + } + } + _ => Err(bad(cmd, "`parent` is a parent id, a parent name or null")), + } +} + +/// Layout › Pages › Parent Options: Based on Parent. A base that would make a cycle is refused. +pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result { + const CMD: &str = "layout.parents.options"; + let d = &s.doc()?.doc; + let Some(Some(id)) = parent_ref(CMD, d, p.get("parent"))? else { return Err(bad(CMD, "`parent` names the parent to change")) }; + let pi = d.parent_index(id).ok_or_else(|| bad(CMD, format!("no parent with id {}", id.0)))?; + let base = parent_ref(CMD, d, p.get("basedOn"))?.ok_or_else(|| bad(CMD, "missing `basedOn` (a parent, or null for [None])"))?; + let label = |d: &designcraft_doc::Document, i: usize| d.parents.get(i).and_then(|x| x.parent.as_ref()).map(|i| i.label()); + if let Some(b) = base + && d.parent_base_cycles(pi, b) + { + let (name, b) = (label(d, pi).unwrap_or_default(), d.parent_index(b).and_then(|i| label(d, i)).unwrap_or_default()); + return Err(bad(CMD, format!("{name} cannot be based on {b}: that would make a cycle"))); + } + let out = json!({"parent": label(d, pi), "basedOn": base.and_then(|b| d.parent_index(b)).and_then(|i| label(d, i))}); + if d.parents.get(pi).and_then(|x| x.parent.as_ref()).and_then(|i| i.based_on) == base { + return Ok(out); + } + s.edit(|d, _| { + let info = d.parents.get_mut(pi).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()).ok_or_else(|| bad(CMD, "no such parent"))?; + info.based_on = base; + Ok(out) + }) +} + +#[cfg(test)] +mod parent_margin_tests { + use designcraft_doc::{Document, Margins}; + use serde_json::json; + + use crate::Session; + + fn margins(d: &Document) -> Vec<(Margins, u32, bool)> { + (0..d.page_count()).filter_map(|i| d.page(i)).map(|p| (p.margins, p.columns.count, p.local_margins)).collect() + } + + #[test] + fn document_setup_margins_let_pages_follow_their_parent_page_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"pages": [1], "margins": 100})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100, "columns": 3})).unwrap(); + s.execute("layout.documentSetup", &json!({"margins": 20})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + let twenty = Margins::uniform(20.0); + assert_eq!(m, vec![(twenty, 1, false), (twenty, 1, false), (twenty, 3, true)], "own columns stay local"); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 50})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[1], (Margins::uniform(50.0), 1, false), "page 2 follows the parent page again"); + assert_eq!(m[2], (twenty, 3, true)); + } + + #[test] + fn pages_follow_their_parent_pages_margins_unless_set_locally() { + let mut s = Session::new(); + // Facing: [1] [2 3]; page 3 gets its own margins. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); + // A's left page: only the left document page follows. + let left = json!({"top": 10, "bottom": 20, "inside": 30, "outside": 40}); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "pages": [0], "margins": left, "columns": 2})).unwrap(); + let l = Margins { top: 10.0, bottom: 20.0, inside: 30.0, outside: 40.0 }; + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m, vec![(Margins::uniform(36.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); + // A's right page: page 1 follows, page 3 keeps its own. + s.execute("layout.marginsAndColumns", &json!({"parent": "A-Parent", "pages": [1], "margins": 50})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m, vec![(Margins::uniform(50.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); + // A page inserted after page 1 pushes page 2 to the right: it takes the right parent page's. + s.execute("layout.pages.insert", &json!({"after": 0, "count": 1, "parent": "A"})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[1], (l, 2, false), "the new left page"); + assert_eq!(m[2], (Margins::uniform(50.0), 1, false), "old page 2, now a right page"); + assert_eq!(m[3], (Margins::uniform(100.0), 1, true)); + assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); + } + + #[test] + fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { + let mut s = Session::new(); + // Facing: [1] [2 3]. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "B", "margins": 70, "columns": 3})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "the page takes B's margins"); + // Page 3 is given margins equal to A's: they are its own all the same. + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 36})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 20})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[0], (Margins::uniform(20.0), 1, false)); + assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "B's page is not A's"); + assert_eq!(m[2], (Margins::uniform(36.0), 1, true)); + // Back to A: page 2 follows A again. + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "A"})).unwrap(); + assert_eq!(margins(&s.doc().unwrap().doc)[1], (Margins::uniform(20.0), 1, false)); + } + + #[test] + fn parent_guides_show_on_pages_but_stay_on_the_parent() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + // A vertical guide 100 pt into A's right page (x = 612 in the parent spread). + s.execute("guide.add", &json!({"spread": {"kind": "parent", "index": 0}, "page": 1, "orientation": "vertical", "position": 712})).unwrap(); + let d = &s.doc().unwrap().doc; + let right = d.page(0).unwrap(); + let shown: Vec = d.parent_guides(right).map(|g| g.position).collect(); + assert_eq!(shown, vec![100.0], "moved to the document page's place"); + assert_eq!(d.parent_guides(d.page(1).unwrap()).count(), 0, "the left page shows A's left page's guides"); + // Not the document page's own: not listed, moved or deleted from there. + assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap(), json!([])); + assert!(s.execute("guide.move", &json!({"spread": 0, "page": 0, "index": 0, "position": 50})).is_err()); + assert!(s.execute("guide.delete", &json!({"spread": 0, "page": 0, "index": 0})).is_err()); + } +} + +#[cfg(test)] +mod parent_tests { + use serde_json::json; + + fn parent_of(s: &crate::Session, page: usize) -> Option { + s.doc().unwrap().doc.page(page).unwrap().parent.map(|p| p.0) + } + + #[test] + fn parents_with_one_prefix_stay_distinct() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let a = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Parent"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Half"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); + assert_eq!(parent_of(&s, 0), Some(b)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Half"})).unwrap(); + assert_eq!(parent_of(&s, 1), Some(b)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Parent"})).unwrap(); + assert_eq!(parent_of(&s, 1), Some(a)); + // A prefix two parents share, or a name no parent has, is an error and changes nothing. + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG"})).is_err()); + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "Nope"})).is_err()); + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": 999_999})).is_err()); + assert_eq!(parent_of(&s, 1), Some(a)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "[None]"})).unwrap(); + assert_eq!(parent_of(&s, 1), None); + } + + #[test] + fn edit_parents_shows_the_named_parent() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let ids: Vec = + ["B", "C", "D"].iter().map(|p| s.execute("layout.parents.new", &json!({"prefix": p})).unwrap()["id"].as_u64().unwrap()).collect(); + s.ui_requests.clear(); + let r = s.execute("layout.parents.edit", &json!({"parent": ids[2]})).unwrap(); + assert_eq!(r, json!({"on": true, "parent": ids[2]})); + assert!(s.doc().unwrap().editing_parents); + let n = s.doc().unwrap().doc.parent_index(designcraft_doc::SpreadId(ids[2])).unwrap(); + let shown: Vec<_> = s + .ui_requests + .iter() + .filter_map(|r| match r { + crate::UiRequest::View { params } => params.get("showSpread").cloned(), + _ => None, + }) + .collect(); + assert_eq!(shown, vec![json!({"kind": "parent", "index": n})]); + let r = s.execute("layout.parents.edit", &json!({"on": true, "parent": "C-Parent"})).unwrap(); + assert_eq!(r["parent"], ids[1]); + assert!(s.execute("layout.parents.edit", &json!({"parent": "Z-Parent"})).is_err()); + let r = s.execute("layout.parents.edit", &json!({"on": false})).unwrap(); + 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![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![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}"); + } + + fn columns_of(s: &crate::Session, abs: usize) -> (Option>, bool) { + let pg = s.doc().unwrap().doc.page(abs).unwrap().clone(); + (pg.columns.positions, pg.local_margins) + } + + /// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked. + fn three_columns(pages: u64) -> crate::Session { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": pages})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap(); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + s + } + + /// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring + /// gutter and the margin, makes a following page's columns its own, and undoes in one step. + #[test] + fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() { + let mut s = three_columns(1); + assert_eq!(columns_of(&s, 0), (None, false), "the page follows A"); + let mv = + |s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x})); + let r = mv(&mut s, 0, 236.0).unwrap(); + assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0})); + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0])); + // Past the next gutter: it stops there, and the middle column has no width. + assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0)); + // Past the right margin: the last gutter stops 12 pt (its width) inside it. + assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0])); + // Past the left margin. + assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0])); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag"); + } + + /// On a parent page the drag changes the parent, and the document pages that follow it get + /// the new columns; a page with columns of its own keeps them. + #[test] + fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() { + let mut s = three_columns(3); + // Page 1, the left page of spread 1, gets columns of its own. + let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0; + s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap(); + let own = columns_of(&s, 1); + assert!(own.1 && own.0.is_some()); + let d = &s.doc().unwrap().doc; + // A's right page: the one page 0 (a right page) and page 2 follow. + let x0 = d.parents[0].pages[1].margin_rect().x0; + s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap(); + let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0]; + assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone())); + assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false)); + assert_eq!(columns_of(&s, 2), (Some(moved), false)); + assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own"); + // The same parent page by spread reference. + let spread = json!({"kind": "parent", "index": 0}); + s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap(); + assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0); + } + + /// A drag's preview in one document is put back there when another document became active. + #[test] + fn a_column_guide_preview_is_cancelled_in_its_own_document() { + let mut s = three_columns(1); + let uid = s.doc().unwrap().uid; + s.begin_interaction("Move Column Guide").unwrap(); + s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + s.cancel_interaction_of(uid); + s.set_active(0); + assert!(s.doc().unwrap().interaction.is_none()); + assert_eq!(columns_of(&s, 0), (None, false)); + } + + #[test] + fn move_column_guide_refuses_while_locked_and_hostile_params() { + let mut s = three_columns(1); + s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap(); + assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err()); + assert_eq!(columns_of(&s, 0), (None, false)); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + for p in [ + json!({"page": 0, "gutter": 0}), + json!({"page": 0, "gutter": 0, "position": f64::NAN}), + json!({"page": 0, "gutter": 0, "position": "200"}), + json!({"page": 0, "gutter": 2, "position": 200}), + json!({"page": 0, "gutter": u64::MAX, "position": 200}), + json!({"page": 0, "gutter": -1, "position": 200}), + json!({"page": 0, "gutter": 0.5, "position": 200}), + json!({"page": 9, "gutter": 0, "position": 200}), + json!({"page": u64::MAX, "gutter": 0, "position": 200}), + json!({"gutter": 0, "position": 200}), + json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}), + json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}), + json!({"page": 0, "gutter": 0, "position": f64::MAX}), + ] { + let r = s.execute("layout.moveColumnGuide", &p); + // A huge but finite position is clamped to the margin; everything else is refused. + assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}"); + } + } +} + +#[cfg(test)] +mod parent_base_tests { + use serde_json::{Value, json}; + + fn rect(s: &mut crate::Session, spread: Value, r: [f64; 4], swatch: &str) -> u64 { + let id = s.execute("frame.create", &json!({"spread": spread, "rect": r, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": swatch})).unwrap(); + id + } + + fn page(s: &crate::Session, abs: usize) -> designcraft_render::Rendered { + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&s.doc().unwrap().doc, &s.cache, abs, 1.0, false, &Default::default()).unwrap() + } + + /// Parent A (black), parent B based on A (cyan, half over A's square), and a page item (paper) + /// over B's: the page draws A, then B, then its own items. + #[test] + fn based_on_parent_draws_base_items_under_parent_items() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Opening"})).unwrap(); + rect(&mut s, json!({"kind": "parent", "index": 0}), [0.0, 0.0, 200.0, 200.0], "[Black]"); + rect(&mut s, json!({"kind": "parent", "index": 1}), [100.0, 0.0, 300.0, 200.0], "C=100 M=0 Y=0 K=0"); + rect(&mut s, json!(1), [150.0, 0.0, 190.0, 40.0], "[Paper]"); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); + let black = |p: [u8; 4]| p[0] < 80 && p[1] < 80 && p[2] < 80; + let cyan = |p: [u8; 4]| p[0] < 80 && p[1] > 120 && p[2] > 180; + let paper = |p: [u8; 4]| p[0] > 240 && p[1] > 240 && p[2] > 240; + assert!(paper(page(&s, 1).pixel(50, 100)), "B alone does not show A's items"); + + let r = s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap(); + assert_eq!(r, json!({"parent": "B-Opening", "basedOn": "A-Parent"})); + let img = page(&s, 1); + assert!(black(img.pixel(50, 100)), "A's item: {:?}", img.pixel(50, 100)); + assert!(cyan(img.pixel(150, 150)), "B's item over A's: {:?}", img.pixel(150, 150)); + assert!(paper(img.pixel(170, 20)), "the page's item over B's: {:?}", img.pixel(170, 20)); + assert!(black(page(&s, 0).pixel(50, 100)) && !cyan(page(&s, 0).pixel(250, 100)), "page 1 uses A only"); + // Parent B itself shows A's items under its own. + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let placed = [designcraft_render::Placed { spread: designcraft_doc::SpreadRef::Parent(1), xf: designcraft_geom::Affine::IDENTITY }]; + let opts = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = rr.render(&s.doc().unwrap().doc, &s.cache, &placed, 400, 400, designcraft_geom::Affine::IDENTITY, &opts); + assert!(black(img.pixel(50, 100)) && cyan(img.pixel(150, 150)), "{:?} {:?}", img.pixel(50, 100), img.pixel(150, 150)); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["parents"][1]["basedOn"], "A-Parent"); + assert_eq!(inspect["parents"][0]["basedOn"], Value::Null); + + // Cycles are refused. + assert!(s.execute("layout.parents.options", &json!({"parent": "A", "basedOn": "B"})).is_err()); + assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "B-Opening"})).is_err()); + assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "Z"})).is_err()); + + s.execute("edit.undo", &json!({})).unwrap(); + assert!(paper(page(&s, 1).pixel(50, 100)), "undo clears Based on Parent"); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": null})).unwrap(); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["parents"][1]["basedOn"], Value::Null); + } + + /// A's page-number marker shown on parent XYZ (based on A) reads XYZ, as markers on XYZ's own items do. + #[test] + fn base_parent_markers_read_the_showing_parent() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "XYZ"})).unwrap(); + s.execute("layout.parents.options", &json!({"parent": "XYZ", "basedOn": "A"})).unwrap(); + let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sid = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let glyphs = |on| -> usize { + let cs = s.cache.get_on(&d, sid, on); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(glyphs(designcraft_compose::ShownOn::Own), 1, "A"); + assert_eq!(glyphs(designcraft_compose::ShownOn::Parent(1)), 3, "XYZ"); + } + + /// A file whose parents are based on each other opens, with the cycle broken and reported. + #[test] + fn opening_a_parent_cycle_breaks_it_with_a_warning() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + let mut d = s.doc().unwrap().doc.as_ref().clone(); + let (a, b) = (d.parents[0].id, d.parents[1].id); + std::sync::Arc::make_mut(&mut d.parents[0]).parent.as_mut().unwrap().based_on = Some(b); + std::sync::Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a); + let b64 = crate::cmd::base64_encode(&crate::cmd::to_bytes(&d).unwrap()); + let r = s.execute("file.openBytes", &json!({"name": "cycle", "base64": b64})).unwrap(); + assert!(r["warnings"].to_string().contains("cycle"), "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None); + assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(a)); + } +} diff --git a/crates/engine/src/cmd/layout/registry.rs b/crates/engine/src/cmd/layout/registry.rs new file mode 100644 index 0000000000..8f0d2405f9 --- /dev/null +++ b/crates/engine/src/cmd/layout/registry.rs @@ -0,0 +1,742 @@ +use super::layers::set_layer; +use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width}; +use super::pages::{document_setup, page_options, page_size}; +use super::parents::{parent_options, parent_ref, parent_spread_index}; +use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "layout.pages.insert", + "Insert Pages…", + ["Layout", "Pages"], + Some("Cmd+Shift+P"), + "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}", + has_doc, + |s, p| { + const ID: &str = "layout.pages.insert"; + // A count that is given but is not a number of pages used to insert one page: + // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped. + let count = match p.get("count").filter(|v| !v.is_null()) { + None => 1, + Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) { + Some(n) => n, + None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))), + }, + }; + let st = s.doc()?; + let n = st.doc.page_count(); + // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end` + // pins deliberately. + let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); + let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); + s.edit(|d, _| { + let ids = d.insert_pages(Some(after), count, parent)?; + Ok(json!({"pages": ids.len(), "total": d.page_count()})) + }) + } + ), + cmd!( + "layout.rotateSpreadView", + "Rotate Spread", + [], + None, + "{spread?: index (0), angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn one spread on screen (output is unaffected)", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let angle = match p.get("angle") { + None => 90, + Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?, + }; + if angle % 90 != 0 { + return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees")); + } + s.edit(|d, _| { + let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?; + let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?; + pg.view_rotation = if angle == 0 { 0 } else { ((pg.view_rotation as i64 + angle.div_euclid(90)).rem_euclid(4)) as u8 }; + Ok(json!({"rotation": pg.view_rotation as u32 * 90})) + }) + } + ), + cmd!("layout.pages.delete", "Delete Pages", ["Layout", "Pages"], None, "{pages: [index]}", has_doc, |s, p| { + let pages: Vec = p + .get("pages") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) + .unwrap_or_default(); + s.edit(|d, sel| { + d.delete_pages(&pages)?; + sel.items.retain(|i| d.item(*i).is_some()); + sel.text = sel.text.filter(|t| d.story(t.story).is_some()); + ok() + }) + }), + cmd!("layout.pages.move", "Move Pages…", ["Layout", "Pages"], None, "{from, to}", has_doc, |s, p| { + let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing from"))? as usize; + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing to"))? as usize; + s.edit(|d, _| { + d.move_page(from, to)?; + ok() + }) + }), + cmd!( + "layout.pages.duplicateSpread", + "Duplicate Spread", + ["Layout", "Pages"], + None, + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); + s.edit(|d, _| { + let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + // The copies land at `to`..`to + n` of the document with them in it. + if to.is_some_and(|t| t > d.page_count()) { + return Err(bad("layout.pages.duplicateSpread", "no such page")); + } + let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; + let parent = sp.pages.first().and_then(|p| p.parent); + d.insert_pages(Some(after), sp.pages.len(), parent)?; + // The copies are the pages they copy: size, margins, columns, guides, liquid rule. + for (k, src) in sp.pages.iter().enumerate() { + let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; + let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); + let pg = &mut nsp.pages[npi]; + let dx = pg.x - src.x; + // 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; + } + nsp.relayout(); + } + // Copy items onto the new spread(s) at the same positions. + let src = d.clone(); + let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); + let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; + crate::cmd::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; + let first = after + 1; + let Some(to) = to else { return Ok(json!({"page": first})) }; + // Moving a page shifts the ones between; order the moves so each copy keeps + // its place among the others. + let n = sp.pages.len(); + if first < to { + for k in (0..n).rev() { + d.move_page(first + k, to + k)?; + } + } else { + for k in 0..n { + d.move_page(first + k, to + k)?; + } + } + Ok(json!({"page": to})) + }) + } + ), + cmd!( + "layout.pages.applyParent", + "Apply Parent to Pages…", + ["Layout", "Pages"], + None, + "{pages: [index], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)}", + has_doc, + |s, p| { + let pages: Vec = p + .get("pages") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) + .unwrap_or_default(); + let parent = parent_ref("layout.pages.applyParent", &s.doc()?.doc, p.get("parent"))?.unwrap_or(None); + s.edit(|d, _| { + d.apply_parent(&pages, parent)?; + ok() + }) + } + ), + cmd!("layout.parents.new", "New Parent…", ["Layout", "Pages"], None, "{prefix?, name?: \"Parent\"}", has_doc, |s, p| { + 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 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})) + }) + }), + cmd!( + "layout.parents.delete", + "Delete Parent", + [], + None, + "{id: parent spread id} — unapply the parent and keep local overrides as ordinary items", + has_doc, + |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?; + s.edit(|d, sel| { + d.delete_parent(id)?; + *sel = Default::default(); + ok() + })?; + let st = s.doc_mut()?; + if st.doc.parents.is_empty() { + st.editing_parents = false; + } + ok() + } + ), + cmd!( + noundo "layout.parents.edit", + "Edit Parents", + [], + None, + "{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent", + has_doc, + |s, p| { + const ID: &str = "layout.parents.edit"; + let doc = s.doc()?.doc.clone(); + let parent = match p.get("parent") { + None | Some(Value::Null) => None, + v => match parent_ref(ID, &doc, v)? { + Some(Some(id)) => Some(id), + _ => return Err(bad(ID, "`parent` names a parent to show")), + }, + }; + let st = s.doc_mut()?; + let was = st.editing_parents; + let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was); + st.editing_parents = on; + st.selection = Default::default(); + st.bump_revision(); + // The parent to show: the one asked for, or the first when editing starts. + let index = match parent { + Some(id) => doc.parent_index(id), + None if !was && !doc.parents.is_empty() => Some(0), + None => None, + }; + let shown = index.filter(|_| on); + if let Some(i) = shown { + s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) }); + } + Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)})) + } + ), + cmd!( + "layout.parents.options", + "Parent Options…", + [], + None, + "{parent: id|\"B-Parent\"|\"B\", basedOn: id|\"A-Parent\"|\"A\"|null (= [None])} — the parent shows its base's items under its own, and so do the pages that use it → {parent, basedOn}", + has_doc, + parent_options + ), + cmd!( + "layout.marginsAndColumns", + "Margins and Columns…", + ["Layout"], + None, + "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins), parent? (a parent's prefix, label or index)} — document pages take these as their own margins; with `parent` the parent's pages change instead (`pages` then indexes them), and so do the document pages without their own", + has_doc, + |s, p| { + let pages: Option> = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); + let parent = match p.get("parent") { + None | Some(Value::Null) => None, + Some(v) => Some(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad("layout.marginsAndColumns", "no such parent"))?), + }; + let p = p.clone(); + let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let before = adjust.then(|| margin_boxes(d)); + if let Some(pi) = parent { + let n = d.parents.get(pi).map_or(0, |sp| sp.pages.len()); + let sp = d.parents.get_mut(pi).map(std::sync::Arc::make_mut); + if let Some(sp) = sp { + for qi in pages.clone().unwrap_or_else(|| (0..n).collect()) { + if let Some(pg) = sp.pages.get_mut(qi) { + set_margins_columns(pg, &p); + } + } + } + d.sync_parent_margins(); + } else { + let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); + for abs in all { + let Some((si, pi)) = d.page_loc(abs) else { continue }; + let Some(pg) = d.spreads.get_mut(si).map(std::sync::Arc::make_mut).and_then(|sp| sp.pages.get_mut(pi)) else { continue }; + set_margins_columns(pg, &p); + pg.local_margins = true; + } + } + if let Some(b) = before { + adjust_layout(d, &b); + } + ok() + }) + } + ), + cmd!( + "layout.moveColumnGuide", + "Move Column Guide", + [], + None, + "{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked", + column_guides_unlocked, + move_column_guide + ), + cmd!( + "layout.documentSetup", + "Document Setup…", + ["File"], + Some("Cmd+Alt+P"), + "{width?, height?, pages?: count, startPage?: n, facingPages?, binding?: leftToRight|rightToLeft, intent?: print|web|mobile, margins?: n | {top, bottom, inside, outside} (every page and parent page), bleed?, slug?: n | [top, bottom, inside, outside], adjustLayout?: bool (objects follow the new page size and margins)} → the document setup (margins: the first page's)", + has_doc, + |s, p| { + let p = p.clone(); + if p.as_object().is_none_or(|o| o.is_empty()) { + return Ok(document_setup(&s.doc()?.doc)); + } + let edges = |k: &str| -> Option<[f64; 4]> { + match p.get(k)? { + Value::Array(a) if a.len() == 4 => { + let v: Vec = a.iter().filter_map(Value::as_f64).map(|v| v.max(0.0)).collect(); + (v.len() == 4).then(|| [v[0], v[1], v[2], v[3]]) + } + v => v.as_f64().map(|b| [b.max(0.0); 4]), + } + }; + let (bleed, slug) = (edges("bleed"), edges("slug")); + for k in ["width", "height"] { + if let Some(v) = p.get(k) + && !v.as_f64().is_some_and(|x| x > 0.0 && x <= 15552.0) + { + return Err(bad("layout.documentSetup", format!("{k}: page size out of range (0 < size ≤ 216 in)"))); + } + } + let margins: Option = match p.get("margins") { + None => None, + Some(Value::Number(n)) => n.as_f64().map(Margins::uniform), + Some(v) => serde_json::from_value(v.clone()).ok(), + }; + if margins.is_some_and(|m| ![m.top, m.bottom, m.inside, m.outside].iter().all(|v| v.is_finite() && (0.0..=15552.0).contains(v))) { + return Err(bad("layout.documentSetup", "margins: 0 to 216 in")); + } + if p.get("margins").is_some() && margins.is_none() { + return Err(bad("layout.documentSetup", "margins: a number or {top, bottom, inside, outside}")); + } + let intent: Option = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()); + let pages = p.get("pages").and_then(Value::as_u64).map(|n| n.clamp(1, 9999) as usize); + let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let before = adjust.then(|| margin_boxes(d)); + if let Some(f) = p.get("facingPages").and_then(Value::as_bool) { + d.settings.facing_pages = f; + } + if let Some(b) = p.get("binding").and_then(Value::as_str) { + d.settings.right_to_left_binding = b == "rightToLeft"; + d.order_parent_pages(); + } + if let Some(b) = bleed { + d.settings.bleed = b; + } + if let Some(b) = slug { + d.settings.slug = b; + } + if let Some(i) = intent { + d.settings.intent = i; + } + if let Some(n) = p.get("startPage").map(|v| start_number("layout.documentSetup", "startPage", v)).transpose()? { + if !d.sections.iter().any(|x| x.start == 0) { + d.sections.insert( + 0, + designcraft_doc::Section { + start: 0, + start_number: None, + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }, + ); + } + if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) { + first.start_number = Some(n); + } + } + // Number of Pages: add at the end (with the last page's parent) or remove from the end. + if let Some(n) = pages { + let have = d.page_count(); + if n > have { + let parent = d.page_loc(have - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); + d.insert_pages(Some(have - 1), n - have, parent)?; + } else if n < have { + d.delete_pages(&(n..have).collect::>())?; + } + } + let w = p.get("width").and_then(Value::as_f64); + let h = p.get("height").and_then(Value::as_f64); + // Pages with a liquid rule re-flow their objects (Adjust Layout aside). + let liquid_before: Vec<(usize, usize, designcraft_geom::Rect)> = if adjust { + vec![] + } else { + d.spreads + .iter() + .enumerate() + .flat_map(|(si, sp)| { + sp.pages.iter().enumerate().filter(|(_, pg)| !pg.liquid.is_off()).map(move |(pi, pg)| (si, pi, pg.bounds())) + }) + .collect() + }; + if w.is_some() || h.is_some() { + d.settings.page_width = w.unwrap_or(d.settings.page_width); + d.settings.page_height = h.unwrap_or(d.settings.page_height); + 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 { + set_page_width(pg, d.settings.page_width); + pg.height = d.settings.page_height; + } + sp.relayout(); + } + } + if let Some(m) = margins { + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + for pg in &mut std::sync::Arc::make_mut(sp).pages { + pg.margins = m; + } + } + // Pages that now match their parent page follow its later changes; local + // columns stay local. + d.unmark_matching_local_margins(); + d.sync_parent_margins(); + } + d.repaginate(); + if let Some(b) = before { + adjust_layout(d, &b); + } + for (si, pi, old) in liquid_before { + crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, old); + } + Ok(document_setup(d)) + }) + } + ), + cmd!( + "layout.pages.toSpread", + "Add Page to Spread", + ["Layout", "Pages"], + None, + "{page (1-based), spread (0-based)} — the page joins that spread (up to 10 pages; the spread stops shuffling)", + has_doc, + |s, p| { + let page = + p.get("page").and_then(Value::as_u64).filter(|n| *n >= 1).ok_or_else(|| bad("layout.pages.toSpread", "missing page"))? as usize; + let spread = p.get("spread").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.toSpread", "missing spread"))? as usize; + s.edit(|d, _| { + d.page_to_spread(page - 1, spread)?; + Ok(json!({"spreads": d.spreads.len()})) + }) + } + ), + cmd!( + "layout.spreadShuffle", + "Allow Selected Spread to Shuffle", + ["Layout", "Pages"], + None, + "{spread (0-based), allow: bool} — off keeps the spread's pages together when pages are added or removed", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let allow = p.get("allow").and_then(Value::as_bool).unwrap_or(true); + s.edit(|d, _| { + let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.spreadShuffle", "no such spread"))?; + std::sync::Arc::make_mut(sp).allow_shuffle = allow; + if allow { + d.repaginate(); + } + ok() + }) + } + ), + cmd!( + "layout.pageSize", + "Page Size", + [], + None, + "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", + has_doc, + page_size + ), + cmd!( + "layout.pageOptions", + "Page Options", + [], + None, + "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", + has_doc, + page_options + ), + cmd!( + noundo "layout.selectPage", + "Select Page", + [], + None, + "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", + has_doc, + |s, p| { + let n = s.doc()?.doc.page_count(); + let page = match p.get("page") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), + }; + let ids: Vec = { + let d = &s.doc()?.doc; + p.get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) + .unwrap_or_default() + }; + let st = s.doc_mut()?; + if page.is_some() { + st.selection = designcraft_doc::Selection::items(ids); + } + st.selection.page = page; + st.bump_revision(); + Ok(json!({"page": page.map(|p| p + 1)})) + } + ), + cmd!( + "layout.section", + "Numbering & Section Options…", + ["Layout"], + None, + "{page (1-based; starts a section there), startNumber?: n (1–99999)|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits|kanji, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", + has_doc, + |s, p| { + let edit = SectionEdit::parse("layout.section", s, p)?; + s.edit(|d, _| { + edit.apply(d); + Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>()})) + }) + } + ), + cmd!( + "layout.chapterNumbering", + "Document Chapter Numbering", + [], + None, + "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, start?: n (Start Chapter Numbering at), source?: automatic|userDefined|sameAsPrevious} → {chapterNumber, label, style, source, book: the open book holding the document, or null}; no parameters reports the setting", + has_doc, + |s, p| { + if p.as_object().is_none_or(|o| o.is_empty()) { + return chapter_numbering(s); + } + let edit = ChapterEdit::parse("layout.chapterNumbering", p)?; + s.edit(|d, _| { + edit.apply(d); + ok() + })?; + chapter_numbering(s) + } + ), + cmd!( + "layout.numberingOptions", + "Apply Numbering & Section Options", + [], + None, + "{page, …the settings as layout.section, chapter?: {…the settings as layout.chapterNumbering}} — the section and the document's chapter numbering in one undo step; if either is refused nothing changes", + has_doc, + |s, p| { + let section = SectionEdit::parse("layout.numberingOptions", s, p)?; + let chapter = match p.get("chapter") { + Some(c) => Some(ChapterEdit::parse("layout.numberingOptions", c)?), + None => None, + }; + s.edit(|d, _| { + section.apply(d); + if let Some(chapter) = &chapter { + chapter.apply(d); + } + Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>(), "chapter": d.chapter_label()})) + }) + } + ), + cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing id"))?); + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing to"))?; + s.edit(|d, _| { + let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?; + let to = usize::try_from(to).ok().filter(|t| *t < d.layers.len()).ok_or_else(|| bad("layer.move", "to out of range"))?; + let l = d.layers.remove(i); + d.layers.insert(to, l); + ok() + }) + }), + cmd!(noundo "layer.selectItems", "Select All on Layer", [], None, "{id}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let st = s.doc_mut()?; + let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect(); + st.selection = designcraft_doc::Selection::items(ids); + st.bump_revision(); + ok() + }), + cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| { + let name = str_param(p, "name").map(str::to_string); + let r = s.edit(|d, _| { + let id = LayerId(d.alloc()); + let n = d.layers.len(); + let name = name.clone().unwrap_or_else(|| format!("Layer {}", n + 1)); + d.layers.insert( + 0, + Layer { + id, + name, + color: LAYER_COLORS[n % LAYER_COLORS.len()].1, + visible: true, + locked: false, + printable: true, + show_guides: true, + suppress_wrap_when_hidden: false, + }, + ); + Ok(id) + })?; + s.doc_mut()?.active_layer = r; + Ok(json!({"id": r.0})) + }), + cmd!( + "layer.set", + "Layer Options…", + [], + None, + "{id, name?, color?: [r,g,b] | layer colour name (\"Light Blue\", \"Red\", …), visible?, locked?, printable?, showGuides?, suppressWrapWhenHidden?} — names are unique and not empty", + has_doc, + set_layer + ), + cmd!("layer.delete", "Delete Layer", [], None, "{id}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + s.edit(|d, sel| { + if d.layers.len() < 2 { + return Err(bad("layer.delete", "a document needs at least one layer")); + } + let doomed: Vec<_> = + d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().filter(|i| i.layer == id).map(|i| i.id)).collect(); + for i in doomed { + let _ = d.remove_item(i); + } + d.layers.retain(|l| l.id != id); + *sel = Default::default(); + ok() + }) + }), + cmd!( + "layer.merge", + "Merge Layers", + [], + None, + "{ids: [layer ids], into?: layer id (default: the first)} — their objects move to `into`, the other layers are deleted", + has_doc, + |s, p| { + let ids: Vec = + p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(LayerId).collect()).unwrap_or_default(); + let into = p + .get("into") + .and_then(Value::as_u64) + .map(LayerId) + .or_else(|| ids.first().copied()) + .ok_or_else(|| bad("layer.merge", "no layers"))?; + s.edit(|d, _| { + if d.layer(into).is_none() || ids.iter().any(|l| d.layer(*l).is_none()) { + return Err(bad("layer.merge", "no such layer")); + } + let gone: Vec = ids.iter().copied().filter(|l| *l != into).collect(); + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + if !sp.items.iter().any(|i| gone.contains(&i.layer)) { + continue; + } + for it in &mut std::sync::Arc::make_mut(sp).items { + if gone.contains(&it.layer) { + std::sync::Arc::make_mut(it).layer = into; + } + } + } + d.layers.retain(|l| !gone.contains(&l.id)); + Ok(json!({"merged": gone.len()})) + }) + } + ), + cmd!("layer.deleteUnused", "Delete Unused Layers", [], None, "{} — layers without objects (one layer always stays)", has_doc, |s, _| { + s.edit(|d, _| { + let used: std::collections::HashSet = + d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().map(|i| i.layer)).collect(); + let before = d.layers.len(); + if used.is_empty() { + // Nothing anywhere: keep the first layer. + d.layers.truncate(1); + } else { + d.layers.retain(|l| used.contains(&l.id)); + } + Ok(json!({"deleted": before - d.layers.len()})) + }) + }), + cmd!( + "layer.others", + "Hide/Lock Others", + [], + None, + "{id, hide?: bool, lock?: bool, show?: true (Show All Layers), unlock?: true (Unlock All Layers)} — Hide Others / Lock Others act on every layer but `id`", + has_doc, + |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock")); + s.edit(|d, _| { + for l in &mut d.layers { + if show { + l.visible = true; + } + if unlock { + l.locked = false; + } + if l.id != id { + if hide { + l.visible = false; + } + if lock { + l.locked = true; + } + } + } + ok() + }) + } + ), + cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let st = s.doc_mut()?; + if st.doc.layer(id).is_none() { + return Err(bad("layer.activate", "no such layer")); + } + st.active_layer = id; + st.bump_revision(); + ok() + }), + ] +} diff --git a/crates/engine/src/cmd/layout/sections.rs b/crates/engine/src/cmd/layout/sections.rs new file mode 100644 index 0000000000..11ef139bc2 --- /dev/null +++ b/crates/engine/src/cmd/layout/sections.rs @@ -0,0 +1,251 @@ +use super::*; + +/// A start page number parameter: a whole number in 1..=[`designcraft_doc::MAX_PAGE_NUMBER`]. +pub(super) fn start_number(cmd: &str, key: &str, v: &Value) -> Result { + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .filter(|n| (1..=designcraft_doc::MAX_PAGE_NUMBER).contains(n)) + .ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number from 1 to {}", designcraft_doc::MAX_PAGE_NUMBER))) +} + +/// The largest chapter number (as for page numbers). +const MAX_CHAPTER: u64 = designcraft_doc::MAX_PAGE_NUMBER as u64; + +/// A `layout.section` edit, checked before the document changes. +pub(super) struct SectionEdit { + /// Absolute index of the section's first page. + start: usize, + remove: bool, + /// Some(None): continue from the previous section. + start_number: Option>, + style: Option, + prefix: Option, + include_prefix: Option, + marker: Option, +} + +impl SectionEdit { + pub(super) fn parse(cmd: &str, s: &Session, p: &Value) -> Result { + let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))?; + let n = s.doc()?.doc.page_count() as u64; + if page == 0 || page > n { + return Err(bad(cmd, format!("no page {page}"))); + } + let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string); + if prefix.as_ref().is_some_and(|x| x.chars().count() > 8 || x.contains(['+', ','])) { + return Err(bad(cmd, "section prefix: up to 8 characters, without + or ,")); + } + let start_number = match p.get("startNumber") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => Some(Some(start_number(cmd, "startNumber", v)?)), + }; + let style = match p.get("style") { + None => None, + Some(v) => match serde_json::from_value(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad(cmd, "symbols don't number pages")), + Ok(style) => Some(style), + Err(e) => return Err(bad(cmd, e.to_string())), + }, + }; + Ok(SectionEdit { + start: (page - 1) as usize, + remove: p.get("remove").and_then(Value::as_bool).unwrap_or(false), + start_number, + style, + prefix, + include_prefix: p.get("includePrefix").and_then(Value::as_bool), + marker: p.get("marker").and_then(Value::as_str).map(str::to_string), + }) + } + + pub(super) fn apply(&self, d: &mut designcraft_doc::Document) { + let start = self.start; + // Page 1 always starts a section. + if self.remove && start != 0 { + d.sections.retain(|x| x.start != start); + // The pages after it follow the previous section's numbers again. + d.repaginate(); + return; + } + let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { + start, + start_number: None, + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }); + if let Some(n) = self.start_number { + sec.start_number = n; + } + if let Some(style) = self.style { + sec.style = style; + } + if let Some(prefix) = &self.prefix { + sec.prefix = prefix.clone(); + } + if let Some(on) = self.include_prefix { + sec.include_prefix = on; + } + if let Some(marker) = &self.marker { + sec.marker = marker.clone(); + } + d.sections.retain(|x| x.start != start); + d.sections.push(sec); + d.sections.sort_by_key(|x| x.start); + // Facing pages take their sides from their numbers: odd on the right. + d.repaginate(); + } +} + +/// A `layout.chapterNumbering` edit, checked before the document changes. +pub(super) struct ChapterEdit { + style: Option, + source: Option, + start: Option, +} + +impl ChapterEdit { + pub(super) fn parse(cmd: &str, p: &Value) -> Result { + let style = match p.get("style") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => return Err(bad(cmd, format!("unknown chapter style {v}"))), + Ok(style) => Some(style), + }, + None => None, + }; + let mut source = match p.get("source") { + Some(v) => Some( + serde_json::from_value::(v.clone()) + .map_err(|_| bad(cmd, format!("unknown chapter number source {v}")))?, + ), + None => None, + }; + let start = match p.get("start") { + Some(v) => { + Some(v.as_u64().filter(|n| (1..=MAX_CHAPTER).contains(n)).ok_or_else(|| bad(cmd, format!("chapter start: 1 to {MAX_CHAPTER}")))? + as u32) + } + None => None, + }; + // A start number alone means Start Chapter Numbering at. + if start.is_some() && source.is_none() { + source = Some(designcraft_doc::ChapterSource::UserDefined); + } + Ok(ChapterEdit { style, source, start }) + } + + pub(super) fn apply(&self, d: &mut designcraft_doc::Document) { + if let Some(style) = self.style { + d.settings.chapter_style = style; + } + if let Some(source) = self.source { + d.settings.chapter_source = source; + } + if let Some(n) = self.start { + d.settings.chapter_number = n; + } + } +} + +/// The active document's chapter numbering, and the open book that holds it (what +/// `layout.chapterNumbering {}` reports, read without running a command). +pub fn chapter_numbering(s: &Session) -> Result { + let st = s.doc()?; + let set = &st.doc.settings; + let book = s + .book + .as_ref() + .filter(|b| st.path.as_deref().is_some_and(|p| b.documents.iter().any(|d| std::path::Path::new(d) == std::path::Path::new(p)))) + .map(|b| std::path::Path::new(&b.path).file_stem().map_or_else(|| b.path.clone(), |n| n.to_string_lossy().to_string())); + Ok( + json!({"chapterNumber": set.chapter_number.max(1), "label": st.doc.chapter_label(), "style": set.chapter_style, "source": set.chapter_source, "book": book}), + ) +} + +#[cfg(test)] +mod section_side_tests { + use serde_json::json; + + use crate::Session; + + /// Each spread's pages as (name, side), side `L`, `R` or `S`. + fn spreads(s: &Session) -> Vec> { + let d = &s.doc().unwrap().doc; + let mut abs = 0; + d.spreads + .iter() + .map(|sp| { + sp.pages + .iter() + .map(|p| { + let side = match p.side { + designcraft_doc::PageSide::Left => 'L', + designcraft_doc::PageSide::Right => 'R', + designcraft_doc::PageSide::Single => 'S', + }; + abs += 1; + (d.page_name(abs - 1), side) + }) + .collect() + }) + .collect() + } + + fn pages(v: &[&[(&str, char)]]) -> Vec> { + v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect() + } + + #[test] + fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() { + // Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the + // section starts on page 8, which becomes a lone right-hand page 1; the pages after it + // shuffle and their objects go with them. Undo puts them back. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap(); + let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"] + .as_u64() + .unwrap(); + let page_of = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.page_of_item(designcraft_doc::ItemId(id)).unwrap() + }; + assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9"); + let before = spreads(&s); + s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap(); + assert_eq!( + spreads(&s), + pages(&[ + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + &[("4", 'L'), ("5", 'R')], + &[("6", 'L'), ("7", 'R')], + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + ]) + ); + assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spreads(&s), before); + // On a right page (the video's page 9) nothing moves; an even start on a right page moves + // it to the left. + s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap(); + assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]); + s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap(); + assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]])); + // Removing the section numbers the pages on from 9 again. + s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap(); + assert_eq!(spreads(&s), before); + } + + #[test] + fn single_sided_documents_keep_their_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap(); + s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap(); + let sp = spreads(&s); + assert_eq!(sp.len(), 4); + assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}"); + } +} diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 49663ae932..7a6d3a001e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -592,383 +592,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn png(w: u32, h: u32) -> Vec { - designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() - } - - /// A linked file's size is the document's choice, so a link is read under a cap (#1596). - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn a_linked_file_over_the_cap_is_refused_instead_of_read() { - let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("big.png"); - let bytes = png(40, 40); - std::fs::write(&path, &bytes).unwrap(); - - // Under the cap the file is read and measured as before. - let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read"); - assert_eq!((read.len(), px), (bytes.len(), (40, 40))); - - // Over it the file is refused by name, and the error says what the limit is. - let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused"); - let msg = err.to_string(); - assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}"); - - // The real cap is the one packaging uses, so an ordinary graphic still loads. - assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected"); - let _ = std::fs::remove_dir_all(&dir); - } - - fn pdf(sizes: &[(u32, u32)]) -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap(); - for (i, (width, height)) in sizes.iter().enumerate() { - s.execute("layout.pageSize", &json!({"pages": [i + 1], "width": width, "height": height})).unwrap(); - } - let result = s.execute("file.exportPdf", &json!({})).unwrap(); - super::super::file::base64_decode(result["base64"].as_str().unwrap()) - } - - #[test] - fn anchored_image_lists_its_page_and_resolution() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); - let r = s - .execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})) - .unwrap(); - let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let placed = s - .execute( - "file.place", - &json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}), - ) - .unwrap(); - let id = placed["id"].as_u64().unwrap(); - let before = s.execute("links.list", &json!({})).unwrap(); - assert_eq!(before[0]["uses"][0]["ppi"], 20.0); - s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap(); - let list = s.execute("links.list", &json!({})).unwrap(); - let uses = list[0]["uses"].as_array().unwrap(); - assert_eq!(uses.len(), 1, "anchored image must have one use: {list}"); - assert_eq!(uses[0]["page"], "2"); - assert_eq!(uses[0]["ppi"], 20.0); - } - - #[test] - fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() { - use std::io::Read; - let bytes = super::super::file::eps_place_tests::tiff_eps(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let placed = s.execute("file.place", &json!({"base64":super::super::file::base64_encode(&bytes),"name":"logo.eps"})).unwrap(); - let aid = AssetId(placed["asset"].as_u64().unwrap()); - let before = s.doc().unwrap().doc.clone(); - let html = s.execute("file.exportHtml", &json!({})).unwrap(); - let encoded = html["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); - let png = super::super::file::base64_decode(encoded); - assert!(png.starts_with(b"\x89PNG")); - assert_eq!(designcraft_render::image_size(&png), Some((2, 2))); - assert_eq!(designcraft_render::decode_pixmap(&png).unwrap().data()[0], designcraft_render::decode_pixmap(&bytes).unwrap().data()[0]); - let result = s.execute("file.exportEpub", &json!({})).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(result["base64"].as_str().unwrap()))).unwrap(); - let mut image = Vec::new(); - z.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); - assert_eq!(image, png); - let mut opf = String::new(); - z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); - assert!(opf.contains("media-type=\"image/png\"")); - assert!(!opf.contains("image/tiff")); - assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - s.edit(|d, _| { - Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EOF\n".to_vec()); - Ok(()) - }) - .unwrap(); - let before = s.doc().unwrap().doc.clone(); - for command in ["file.exportHtml", "file.exportEpub"] { - let result = s.execute(command, &json!({})).unwrap(); - assert_eq!(result["warnings"].as_array().unwrap().len(), 1, "{command}"); - if command == "file.exportHtml" { - assert!(!result["text"].as_str().unwrap().contains("("Subtype").is_some_and(|n| n.as_str() == "Image")) - .count(), - 1 - ); - let result = s.execute("file.exportHtml", &json!({})).unwrap(); - let encoded = result["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); - assert_eq!(super::super::file::base64_decode(encoded), proxy); - let result = s.execute("file.exportEpub", &json!({})).unwrap(); - let bytes = super::super::file::base64_decode(result["base64"].as_str().unwrap()); - let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut image = Vec::new(); - archive.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); - assert_eq!(image, proxy); - let mut opf = String::new(); - archive.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); - assert!(opf.contains("media-type=\"image/png\"")); - assert!(!opf.contains("application/postscript")); - assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "exports must leave the source document intact"); - assert_eq!(before.assets[&aid].data.as_slice(), source); - } - - #[test] - fn eps_relink_update_and_folder_relink_retain_source() { - let dir = std::env::temp_dir().join(format!("dc-links-eps-{}", std::process::id())); - std::fs::create_dir_all(dir.join("new")).unwrap(); - let eps = |w: u32| format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 {w} 10\n%%EOF\n"); - let original = dir.join("original.eps"); - let replacement = dir.join("replacement.eps"); - std::fs::write(&original, eps(10)).unwrap(); - std::fs::write(&replacement, eps(30)).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": original.to_string_lossy()})).unwrap(); - let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); - std::fs::write(&original, eps(20)).unwrap(); - assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); - assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((40, 20))); - s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); - let a = &s.doc().unwrap().doc.assets[&aid]; - assert_eq!(a.pixels, Some((60, 20))); - assert_eq!(a.mime, "application/postscript"); - assert_eq!(a.data.as_slice(), eps(30).as_bytes()); - let moved = dir.join("new").join("replacement.eps"); - std::fs::rename(&replacement, &moved).unwrap(); - assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1); - assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str()); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn pdf_relink_and_update_preserve_selected_page_size() { - let dir = std::env::temp_dir().join(format!("dc-links-pdf-size-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("original.pdf"); - let replacement = dir.join("replacement.pdf"); - std::fs::write(&original, pdf(&[(10, 10), (20, 30)])).unwrap(); - std::fs::write(&replacement, pdf(&[(40, 40), (50, 60)])).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); - let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); - s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); - let a = &s.doc().unwrap().doc.assets[&aid]; - assert_eq!(a.page, 1); - assert_eq!(a.pixels, Some((50, 60))); - assert!(designcraft_render::render_pdf_page(&a.data, a.page as usize, 64).is_some()); - std::fs::write(&replacement, pdf(&[(70, 70), (80, 90)])).unwrap(); - assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); - assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((80, 90))); - let raster = dir.join("replacement.png"); - std::fs::write(&raster, png(4, 5)).unwrap(); - s.execute("links.relink", &json!({"asset": aid.0, "path": raster.to_string_lossy()})).unwrap(); - assert_eq!(s.doc().unwrap().doc.assets[&aid].page, 0); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn pdf_reload_rejects_missing_page_without_edit() { - let full = pdf(&[(10, 10), (20, 30)]); - let short = pdf(&[(10, 10)]); - let dir = std::env::temp_dir().join(format!("dc-links-pdf-page-{}", std::process::id())); - std::fs::create_dir_all(dir.join("new")).unwrap(); - let original = dir.join("book.pdf"); - let replacement = dir.join("new/book.pdf"); - std::fs::write(&replacement, short.clone()).unwrap(); - for command in ["links.relink", "links.relinkFolder", "links.update"] { - std::fs::write(&original, &full).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); - let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); - if command == "links.update" { - std::fs::write(&original, &short).unwrap(); - } - let st = s.doc().unwrap(); - let before = st.doc.clone(); - let revision = st.revision; - let undo = st.history.undo.len(); - let journal = s.journal.len(); - let params = match command { - "links.relinkFolder" => json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}), - "links.update" => json!({"asset": aid.0}), - _ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}), - }; - let err = s.execute(command, ¶ms).unwrap_err(); - assert!(err.to_string().contains("PDF page 2"), "{command}: {err}"); - let st = s.doc().unwrap(); - assert!(Arc::ptr_eq(&st.doc, &before), "{command}"); - assert_eq!(st.revision, revision); - assert_eq!(st.history.undo.len(), undo); - assert_eq!(s.journal.len(), journal); - assert!(designcraft_render::render_pdf_page(&st.doc.assets[&aid].data, 1, 64).is_some()); - } - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn link_status_relink_update_embed() { - let dir = std::env::temp_dir().join(format!("dc-links-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let a = dir.join("a.png"); - let b = dir.join("b.png"); - std::fs::write(&a, png(10, 10)).unwrap(); - std::fs::write(&b, png(20, 10)).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": a.to_string_lossy(), "x": 72, "y": 72, "width": 72})).unwrap(); - let l = s.execute("links.list", &json!({})).unwrap(); - assert_eq!(l[0]["status"], "ok"); - assert_eq!(l[0]["uses"][0]["ppi"], 10.0, "10 px over 1 inch"); - let aid = l[0]["asset"].as_u64().unwrap(); - // The file changes on disk. - std::fs::write(&a, png(30, 10)).unwrap(); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified"); - s.execute("links.update", &json!({})).unwrap(); - let l = s.execute("links.list", &json!({})).unwrap(); - assert_eq!(l[0]["status"], "ok"); - assert_eq!(l[0]["pixels"], json!([30, 10])); - // Missing, then relinked. - std::fs::remove_file(&a).unwrap(); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing"); - s.execute("links.relink", &json!({"asset": aid, "path": b.to_string_lossy()})).unwrap(); - let l = s.execute("links.list", &json!({})).unwrap(); - assert_eq!((l[0]["status"].as_str(), l[0]["name"].as_str()), (Some("ok"), Some("b.png"))); - // Go to selects the frame; embed forgets the file. - let r = s.execute("links.goTo", &json!({"asset": aid})).unwrap(); - assert_eq!(r["ids"].as_array().unwrap().len(), 1); - s.execute("links.embed", &json!({"asset": aid})).unwrap(); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; #[cfg(test)] mod relink_folder_tests { diff --git a/crates/engine/src/cmd/links/tests.rs b/crates/engine/src/cmd/links/tests.rs new file mode 100644 index 0000000000..8089b24ce1 --- /dev/null +++ b/crates/engine/src/cmd/links/tests.rs @@ -0,0 +1,375 @@ +use super::*; + +fn png(w: u32, h: u32) -> Vec { + designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() +} + +/// A linked file's size is the document's choice, so a link is read under a cap (#1596). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_linked_file_over_the_cap_is_refused_instead_of_read() { + let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.png"); + let bytes = png(40, 40); + std::fs::write(&path, &bytes).unwrap(); + + // Under the cap the file is read and measured as before. + let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read"); + assert_eq!((read.len(), px), (bytes.len(), (40, 40))); + + // Over it the file is refused by name, and the error says what the limit is. + let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused"); + let msg = err.to_string(); + assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}"); + + // The real cap is the one packaging uses, so an ordinary graphic still loads. + assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected"); + let _ = std::fs::remove_dir_all(&dir); +} + +fn pdf(sizes: &[(u32, u32)]) -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap(); + for (i, (width, height)) in sizes.iter().enumerate() { + s.execute("layout.pageSize", &json!({"pages": [i + 1], "width": width, "height": height})).unwrap(); + } + let result = s.execute("file.exportPdf", &json!({})).unwrap(); + super::super::file::base64_decode(result["base64"].as_str().unwrap()) +} + +#[test] +fn anchored_image_lists_its_page_and_resolution() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let placed = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}), + ) + .unwrap(); + let id = placed["id"].as_u64().unwrap(); + let before = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(before[0]["uses"][0]["ppi"], 20.0); + s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let uses = list[0]["uses"].as_array().unwrap(); + assert_eq!(uses.len(), 1, "anchored image must have one use: {list}"); + assert_eq!(uses[0]["page"], "2"); + assert_eq!(uses[0]["ppi"], 20.0); +} + +#[test] +fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() { + use std::io::Read; + let bytes = super::super::file::eps_place_tests::tiff_eps(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let placed = s.execute("file.place", &json!({"base64":super::super::file::base64_encode(&bytes),"name":"logo.eps"})).unwrap(); + let aid = AssetId(placed["asset"].as_u64().unwrap()); + let before = s.doc().unwrap().doc.clone(); + let html = s.execute("file.exportHtml", &json!({})).unwrap(); + let encoded = html["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); + let png = super::super::file::base64_decode(encoded); + assert!(png.starts_with(b"\x89PNG")); + assert_eq!(designcraft_render::image_size(&png), Some((2, 2))); + assert_eq!(designcraft_render::decode_pixmap(&png).unwrap().data()[0], designcraft_render::decode_pixmap(&bytes).unwrap().data()[0]); + let result = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(result["base64"].as_str().unwrap()))).unwrap(); + let mut image = Vec::new(); + z.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); + assert_eq!(image, png); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("media-type=\"image/png\"")); + assert!(!opf.contains("image/tiff")); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + s.edit(|d, _| { + Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EOF\n".to_vec()); + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + for command in ["file.exportHtml", "file.exportEpub"] { + let result = s.execute(command, &json!({})).unwrap(); + assert_eq!(result["warnings"].as_array().unwrap().len(), 1, "{command}"); + if command == "file.exportHtml" { + assert!(!result["text"].as_str().unwrap().contains("("Subtype").is_some_and(|n| n.as_str() == "Image")) + .count(), + 1 + ); + let result = s.execute("file.exportHtml", &json!({})).unwrap(); + let encoded = result["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); + assert_eq!(super::super::file::base64_decode(encoded), proxy); + let result = s.execute("file.exportEpub", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(result["base64"].as_str().unwrap()); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut image = Vec::new(); + archive.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); + assert_eq!(image, proxy); + let mut opf = String::new(); + archive.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("media-type=\"image/png\"")); + assert!(!opf.contains("application/postscript")); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "exports must leave the source document intact"); + assert_eq!(before.assets[&aid].data.as_slice(), source); +} + +#[test] +fn eps_relink_update_and_folder_relink_retain_source() { + let dir = std::env::temp_dir().join(format!("dc-links-eps-{}", std::process::id())); + std::fs::create_dir_all(dir.join("new")).unwrap(); + let eps = |w: u32| format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 {w} 10\n%%EOF\n"); + let original = dir.join("original.eps"); + let replacement = dir.join("replacement.eps"); + std::fs::write(&original, eps(10)).unwrap(); + std::fs::write(&replacement, eps(30)).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": original.to_string_lossy()})).unwrap(); + let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); + std::fs::write(&original, eps(20)).unwrap(); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((40, 20))); + s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); + let a = &s.doc().unwrap().doc.assets[&aid]; + assert_eq!(a.pixels, Some((60, 20))); + assert_eq!(a.mime, "application/postscript"); + assert_eq!(a.data.as_slice(), eps(30).as_bytes()); + let moved = dir.join("new").join("replacement.eps"); + std::fs::rename(&replacement, &moved).unwrap(); + assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1); + assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str()); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pdf_relink_and_update_preserve_selected_page_size() { + let dir = std::env::temp_dir().join(format!("dc-links-pdf-size-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.pdf"); + let replacement = dir.join("replacement.pdf"); + std::fs::write(&original, pdf(&[(10, 10), (20, 30)])).unwrap(); + std::fs::write(&replacement, pdf(&[(40, 40), (50, 60)])).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); + let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); + s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); + let a = &s.doc().unwrap().doc.assets[&aid]; + assert_eq!(a.page, 1); + assert_eq!(a.pixels, Some((50, 60))); + assert!(designcraft_render::render_pdf_page(&a.data, a.page as usize, 64).is_some()); + std::fs::write(&replacement, pdf(&[(70, 70), (80, 90)])).unwrap(); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((80, 90))); + let raster = dir.join("replacement.png"); + std::fs::write(&raster, png(4, 5)).unwrap(); + s.execute("links.relink", &json!({"asset": aid.0, "path": raster.to_string_lossy()})).unwrap(); + assert_eq!(s.doc().unwrap().doc.assets[&aid].page, 0); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pdf_reload_rejects_missing_page_without_edit() { + let full = pdf(&[(10, 10), (20, 30)]); + let short = pdf(&[(10, 10)]); + let dir = std::env::temp_dir().join(format!("dc-links-pdf-page-{}", std::process::id())); + std::fs::create_dir_all(dir.join("new")).unwrap(); + let original = dir.join("book.pdf"); + let replacement = dir.join("new/book.pdf"); + std::fs::write(&replacement, short.clone()).unwrap(); + for command in ["links.relink", "links.relinkFolder", "links.update"] { + std::fs::write(&original, &full).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); + let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); + if command == "links.update" { + std::fs::write(&original, &short).unwrap(); + } + let st = s.doc().unwrap(); + let before = st.doc.clone(); + let revision = st.revision; + let undo = st.history.undo.len(); + let journal = s.journal.len(); + let params = match command { + "links.relinkFolder" => json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}), + "links.update" => json!({"asset": aid.0}), + _ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}), + }; + let err = s.execute(command, ¶ms).unwrap_err(); + assert!(err.to_string().contains("PDF page 2"), "{command}: {err}"); + let st = s.doc().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &before), "{command}"); + assert_eq!(st.revision, revision); + assert_eq!(st.history.undo.len(), undo); + assert_eq!(s.journal.len(), journal); + assert!(designcraft_render::render_pdf_page(&st.doc.assets[&aid].data, 1, 64).is_some()); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn link_status_relink_update_embed() { + let dir = std::env::temp_dir().join(format!("dc-links-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.png"); + let b = dir.join("b.png"); + std::fs::write(&a, png(10, 10)).unwrap(); + std::fs::write(&b, png(20, 10)).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": a.to_string_lossy(), "x": 72, "y": 72, "width": 72})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(l[0]["status"], "ok"); + assert_eq!(l[0]["uses"][0]["ppi"], 10.0, "10 px over 1 inch"); + let aid = l[0]["asset"].as_u64().unwrap(); + // The file changes on disk. + std::fs::write(&a, png(30, 10)).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified"); + s.execute("links.update", &json!({})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(l[0]["status"], "ok"); + assert_eq!(l[0]["pixels"], json!([30, 10])); + // Missing, then relinked. + std::fs::remove_file(&a).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing"); + s.execute("links.relink", &json!({"asset": aid, "path": b.to_string_lossy()})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!((l[0]["status"].as_str(), l[0]["name"].as_str()), (Some("ok"), Some("b.png"))); + // Go to selects the frame; embed forgets the file. + let r = s.execute("links.goTo", &json!({"asset": aid})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + s.execute("links.embed", &json!({"asset": aid})).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/load_styles.rs b/crates/engine/src/cmd/load_styles.rs index acc20f7195..762f7b1fc1 100644 --- a/crates/engine/src/cmd/load_styles.rs +++ b/crates/engine/src/cmd/load_styles.rs @@ -350,358 +350,4 @@ fn list(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use designcraft_color::{Color, Swatch, SwatchValue}; - use designcraft_doc::{CharAttrs, CharacterStyle, ExportTag, GrepStyle, NumberedList, ParaAttrs, ParagraphStyle, Rule, Styles}; - use serde_json::{Value, json}; - - use crate::Session; - - const BASIC: &str = "[Basic Paragraph]"; - - fn para(name: &str, based_on: Option<&str>, size: f64) -> ParagraphStyle { - ParagraphStyle { - name: name.into(), - based_on: based_on.map(str::to_string), - next_style: None, - para: ParaAttrs::default(), - chars: CharAttrs { size: Some(size), ..Default::default() }, - shortcut: String::new(), - } - } - - fn chars(name: &str, based_on: Option<&str>, size: f64) -> CharacterStyle { - CharacterStyle { - name: name.into(), - based_on: based_on.map(str::to_string), - chars: CharAttrs { size: Some(size), ..Default::default() }, - shortcut: String::new(), - } - } - - fn new_doc() -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s - } - - fn file_of(s: &Session) -> String { - super::super::file::base64_encode(&super::super::file::to_bytes(&s.doc().unwrap().doc).unwrap()) - } - - /// A client's document: Body in the brand colour with a GREP style, Head and Quote based on - /// it, Small characters based on Lead, an 11 pt [Basic Paragraph], and a swatch no style uses. - fn client() -> Session { - let mut s = new_doc(); - s.edit(|d, _| { - d.swatches.push(Swatch::color("Brand Teal", Color::cmyk(0.9, 0.0, 0.4, 0.2))); - d.swatches.push(Swatch { - name: "Brand Teal 40%".into(), - value: SwatchValue::Tint { base: "Brand Teal".into(), tint: 0.4 }, - locked: false, - named: true, - hidden: false, - }); - d.swatches.push(Swatch::color("Unused Red", Color::cmyk(0.0, 1.0, 1.0, 0.0))); - let st = d.styles_mut(); - st.para_mut(BASIC).unwrap().chars.size = Some(11.0); - st.character.push(chars("Lead", None, 9.0)); - st.character.push(chars("Small", Some("Lead"), 7.0)); - let mut body = para("Body", Some(BASIC), 10.0); - body.chars.fill = Some("Brand Teal".into()); - body.para.grep_styles = Some(vec![GrepStyle { style: "Small".into(), pattern: r"\d+".into() }]); - st.paragraph.push(body); - let mut head = para("Head", Some("Body"), 20.0); - head.next_style = Some("Body".into()); - st.paragraph.push(head); - let mut quote = para("Quote", Some("Body"), 12.0); - quote.para.rule_above = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); - st.paragraph.push(quote); - Ok(()) - }) - .unwrap(); - s - } - - fn styles(s: &Session) -> Styles { - (*s.doc().unwrap().doc.styles).clone() - } - - #[test] - fn every_style_of_the_file_loads_with_the_swatches_it_uses() { - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - let d = &s.doc().unwrap().doc; - let head = d.styles.para("Head").unwrap(); - assert_eq!((head.based_on.as_deref(), head.next_style.as_deref(), head.chars.size), (Some("Body"), Some("Body"), Some(20.0))); - assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); - assert_eq!(d.styles.char_style("Small").unwrap().based_on.as_deref(), Some("Lead")); - assert_eq!(d.swatch("Brand Teal"), client().doc().unwrap().doc.swatch("Brand Teal")); - assert!(d.swatch("Brand Teal 40%").is_some()); - assert!(d.swatch("Unused Red").is_none(), "a swatch no loaded style uses stays behind"); - assert_eq!(r["paragraph"]["added"], json!(["Body", "Head", "Quote"])); - assert_eq!(r["character"]["added"], json!(["Lead", "Small"])); - assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); - } - - #[test] - fn basic_paragraph_takes_the_definition_of_the_file() { - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para(BASIC).unwrap().chars.size, Some(11.0)); - assert_eq!(r["paragraph"]["replaced"], json!([BASIC])); - } - - #[test] - fn a_named_style_brings_the_styles_it_names() { - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&client()), "paragraph": ["Head"], "character": []})).unwrap(); - let st = styles(&s); - assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"]), "Head is based on Body"); - assert_eq!(r["character"]["added"], json!(["Lead", "Small"]), "Body's GREP style names Small, which is based on Lead"); - assert!(st.para("Quote").is_none()); - assert_eq!(st.para(BASIC).unwrap().chars.size, None, "a style the document has is only replaced when it was asked for"); - assert_eq!(r["paragraph"]["replaced"], json!([])); - assert_eq!(r["swatches"], json!(["Brand Teal"])); - } - - /// The document already has a Body and a Lead of its own. - fn doc_with_body_and_lead() -> Session { - let mut s = new_doc(); - s.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("Body", None, 8.0)); - st.character.push(chars("Lead", None, 5.0)); - Ok(()) - }) - .unwrap(); - s - } - - #[test] - fn a_style_of_the_same_name_takes_the_loaded_definition() { - let mut s = doc_with_body_and_lead(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - let st = styles(&s); - assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(10.0), Some(9.0))); - assert_eq!(r["paragraph"]["replaced"], json!([BASIC, "Body"])); - assert_eq!(r["character"]["replaced"], json!(["Lead"])); - assert_eq!(st.paragraph.iter().filter(|p| p.name == "Body").count(), 1); - } - - #[test] - fn use_existing_keeps_the_styles_of_the_document() { - let mut s = doc_with_body_and_lead(); - let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "useExisting"})).unwrap(); - let st = styles(&s); - assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(8.0), Some(5.0))); - assert_eq!(st.para(BASIC).unwrap().chars.size, None); - assert_eq!(st.para("Head").unwrap().based_on.as_deref(), Some("Body")); - assert_eq!((&r["paragraph"]["added"], &r["paragraph"]["replaced"]), (&json!(["Head", "Quote"]), &json!([]))); - } - - #[test] - fn auto_rename_loads_a_clashing_style_under_a_new_name() { - let mut s = doc_with_body_and_lead(); - let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "autoRename"})).unwrap(); - let st = styles(&s); - assert_eq!((st.para("Body").unwrap().chars.size, st.para("Body 2").unwrap().chars.size), (Some(8.0), Some(10.0))); - assert_eq!((st.char_style("Lead").unwrap().chars.size, st.char_style("Lead 2").unwrap().chars.size), (Some(5.0), Some(9.0))); - let head = st.para("Head").unwrap(); - assert_eq!( - (head.based_on.as_deref(), head.next_style.as_deref()), - (Some("Body 2"), Some("Body 2")), - "the loaded Head follows the loaded Body" - ); - assert_eq!(st.char_style("Small").unwrap().based_on.as_deref(), Some("Lead 2")); - assert_eq!(st.para("Body 2").unwrap().based_on.as_deref(), Some("Basic Paragraph")); - assert_eq!(st.para("Basic Paragraph").unwrap().chars.size, Some(11.0), "a built-in style comes in without its brackets"); - assert_eq!(st.para(BASIC).unwrap().chars.size, None); - assert_eq!(r["paragraph"]["renamed"], json!({"[Basic Paragraph]": "Basic Paragraph", "Body": "Body 2"})); - assert_eq!(r["character"]["renamed"], json!({"Lead": "Lead 2"})); - assert_eq!(r["paragraph"]["replaced"], json!([])); - } - - #[test] - fn a_style_that_is_the_same_in_both_is_no_conflict() { - let mut s = new_doc(); - let file = file_of(&client()); - s.execute("style.load", &json!({"base64": file})).unwrap(); - let loaded = styles(&s); - for conflicts in ["redefine", "autoRename"] { - let r = s.execute("style.load", &json!({"base64": file, "conflicts": conflicts})).unwrap(); - let none = json!({"added": [], "replaced": [], "renamed": {}}); - assert_eq!(r, json!({"paragraph": none, "character": none, "swatches": []}), "{conflicts}"); - } - assert_eq!(styles(&s), loaded); - // Loading nothing leaves no undo step: one Undo takes back the first load. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(styles(&s).para("Head").is_none()); - } - - #[test] - fn loading_is_one_undo_step() { - let mut s = doc_with_body_and_lead(); - let (before, swatches) = (styles(&s), s.doc().unwrap().doc.swatches.clone()); - s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - assert_ne!(styles(&s), before); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(styles(&s), before); - assert_eq!(s.doc().unwrap().doc.swatches, swatches); - } - - #[test] - fn a_swatch_of_the_document_wins_over_the_one_in_the_file() { - let mut s = new_doc(); - s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#ff0000"})).unwrap(); - let mine = s.doc().unwrap().doc.swatch("Brand Teal").cloned(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - assert_eq!(s.doc().unwrap().doc.swatch("Brand Teal").cloned(), mine); - assert_eq!(r["swatches"], json!(["Brand Teal 40%"])); - } - - #[test] - fn a_tint_swatch_brings_its_base_colour() { - let mut src = client(); - src.edit(|d, _| { - let mut ruled = para("Ruled", None, 9.0); - ruled.para.rule_below = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); - d.styles_mut().paragraph.push(ruled); - Ok(()) - }) - .unwrap(); - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Ruled"], "character": []})).unwrap(); - assert_eq!(r["paragraph"]["added"], json!(["Ruled"])); - assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); - } - - #[test] - fn export_tags_and_named_lists_come_with_their_styles() { - let mut src = client(); - src.edit(|d, _| { - d.settings.lists.push(NumberedList { name: "Steps".into(), continue_across_stories: false }); - d.settings.lists.push(NumberedList { name: "Unused".into(), ..Default::default() }); - let st = d.styles_mut(); - st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into(), split: true }); - st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new(), ..Default::default() }); - st.para_mut("Quote").unwrap().para.list_name = Some("Steps".into()); - Ok(()) - }) - .unwrap(); - let mut s = doc_with_body_and_lead(); - s.execute("style.load", &json!({"base64": file_of(&src), "conflicts": "autoRename"})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.styles.export_tag("Head", false).map(|t| (t.tag.as_str(), t.split)), Some(("h1", true)), "Split Document comes along"); - assert_eq!(d.styles.export_tag("Lead 2", true).map(|t| t.tag.as_str()), Some("em"), "the tag follows the new name"); - assert_eq!(d.styles.export_tag("Lead", true), None); - assert_eq!(d.settings.lists.iter().map(|l| (l.name.as_str(), l.continue_across_stories)).collect::>(), [("Steps", false)]); - } - - #[test] - fn styles_load_from_an_idml_file() { - let idml = client().execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": idml, "name": "Client.IDML", "paragraph": ["Head"]})).unwrap(); - let d = &s.doc().unwrap().doc; - let head = d.styles.para("Head").unwrap(); - assert_eq!((head.based_on.as_deref(), head.chars.size), (Some("Body"), Some(20.0))); - assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); - assert!(d.swatch("Brand Teal").is_some()); - assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"])); - } - - #[test] - fn styles_load_from_a_path() { - let path = std::env::temp_dir().join(format!("dc-load-styles-{}.designcraft", std::process::id())); - std::fs::write(&path, super::super::file::to_bytes(&client().doc().unwrap().doc).unwrap()).unwrap(); - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"path": path.to_str().unwrap(), "paragraph": [], "character": ["Small"]})); - std::fs::remove_file(&path).unwrap(); - assert_eq!(r.unwrap()["character"]["added"], json!(["Lead", "Small"])); - assert!(styles(&s).para("Head").is_none()); - } - - #[test] - fn the_file_lists_its_styles_and_which_ones_clash() { - let mut s = doc_with_body_and_lead(); - let file = file_of(&client()); - let name = |n: &str, conflict: bool| json!({"name": n, "conflict": conflict}); - let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); - assert_eq!(r["paragraph"], json!([name(BASIC, true), name("Body", true), name("Head", false), name("Quote", false)])); - assert_eq!(r["character"], json!([name("Lead", true), name("Small", false)])); - s.execute("style.load", &json!({"base64": file})).unwrap(); - let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); - assert!(r["paragraph"].as_array().unwrap().iter().all(|p| p["conflict"] == false), "the same definition is no clash: {r}"); - } - - fn unchanged_after(s: &mut Session, params: Value, why: &str) { - let before = s.doc().unwrap().doc.clone(); - let e = s.execute("style.load", ¶ms).unwrap_err().to_string(); - assert!(e.contains(why), "{e}"); - assert_eq!(s.doc().unwrap().doc, before); - } - - #[test] - fn what_cannot_be_loaded_is_an_error_and_changes_nothing() { - let mut s = doc_with_body_and_lead(); - let file = file_of(&client()); - unchanged_after(&mut s, json!({}), "`path` or `base64`"); - unchanged_after(&mut s, json!({"base64": "AAAA"}), ""); - unchanged_after(&mut s, json!({"base64": "AAAA", "name": "a.idml"}), ""); - unchanged_after(&mut s, json!({"base64": file, "paragraph": ["Head", "Nope"]}), "no paragraph style `Nope`"); - unchanged_after(&mut s, json!({"base64": file, "character": ["[None]"]}), "no character style `[None]`"); - unchanged_after(&mut s, json!({"base64": file, "paragraph": ["[No Paragraph Style]"]}), "no paragraph style"); - unchanged_after(&mut s, json!({"base64": file, "paragraph": "Head"}), "`paragraph` must be a list of style names"); - unchanged_after(&mut s, json!({"base64": file, "character": [1]}), "`character` must be a list of style names"); - unchanged_after(&mut s, json!({"base64": file, "conflicts": "merge"}), "`conflicts`"); - } - - #[test] - fn a_style_that_would_be_based_on_itself_is_refused() { - // In the file B is based on A; in the document A is based on B. - let mut src = new_doc(); - src.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("A", None, 9.0)); - st.paragraph.push(para("B", Some("A"), 9.0)); - st.character.push(chars("X", None, 9.0)); - st.character.push(chars("Y", Some("X"), 9.0)); - Ok(()) - }) - .unwrap(); - let mut s = new_doc(); - s.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("B", None, 9.0)); - st.paragraph.push(para("A", Some("B"), 9.0)); - st.character.push(chars("Y", None, 9.0)); - st.character.push(chars("X", Some("Y"), 9.0)); - Ok(()) - }) - .unwrap(); - unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["B"], "character": []}), "`B` would be based on itself"); - unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": [], "character": ["Y"]}), "`Y` would be based on itself"); - // With A loaded as well both take the file's order. - s.execute("style.load", &json!({"base64": file_of(&src), "character": []})).unwrap(); - assert_eq!((styles(&s).para("A").unwrap().based_on.clone(), styles(&s).para("B").unwrap().based_on.clone()), (None, Some("A".to_string()))); - } - - #[test] - fn a_file_with_a_based_on_loop_of_its_own_does_not_hang() { - let mut src = new_doc(); - src.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("A", Some("B"), 9.0)); - st.paragraph.push(para("B", Some("A"), 9.0)); - st.paragraph.push(para("Next", None, 9.0)); - st.para_mut("Next").unwrap().next_style = Some("Next".into()); - Ok(()) - }) - .unwrap(); - let mut s = new_doc(); - unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["A"]}), "would be based on itself"); - let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Next"]})).unwrap(); - assert_eq!(r["paragraph"]["added"], json!(["Next"])); - } -} +mod tests; diff --git a/crates/engine/src/cmd/load_styles/tests.rs b/crates/engine/src/cmd/load_styles/tests.rs new file mode 100644 index 0000000000..9b9db72ed1 --- /dev/null +++ b/crates/engine/src/cmd/load_styles/tests.rs @@ -0,0 +1,349 @@ +use designcraft_color::{Color, Swatch, SwatchValue}; +use designcraft_doc::{CharAttrs, CharacterStyle, ExportTag, GrepStyle, NumberedList, ParaAttrs, ParagraphStyle, Rule, Styles}; +use serde_json::{Value, json}; + +use crate::Session; + +const BASIC: &str = "[Basic Paragraph]"; + +fn para(name: &str, based_on: Option<&str>, size: f64) -> ParagraphStyle { + ParagraphStyle { + name: name.into(), + based_on: based_on.map(str::to_string), + next_style: None, + para: ParaAttrs::default(), + chars: CharAttrs { size: Some(size), ..Default::default() }, + shortcut: String::new(), + } +} + +fn chars(name: &str, based_on: Option<&str>, size: f64) -> CharacterStyle { + CharacterStyle { + name: name.into(), + based_on: based_on.map(str::to_string), + chars: CharAttrs { size: Some(size), ..Default::default() }, + shortcut: String::new(), + } +} + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} + +fn file_of(s: &Session) -> String { + super::super::file::base64_encode(&super::super::file::to_bytes(&s.doc().unwrap().doc).unwrap()) +} + +/// A client's document: Body in the brand colour with a GREP style, Head and Quote based on +/// it, Small characters based on Lead, an 11 pt [Basic Paragraph], and a swatch no style uses. +fn client() -> Session { + let mut s = new_doc(); + s.edit(|d, _| { + d.swatches.push(Swatch::color("Brand Teal", Color::cmyk(0.9, 0.0, 0.4, 0.2))); + d.swatches.push(Swatch { + name: "Brand Teal 40%".into(), + value: SwatchValue::Tint { base: "Brand Teal".into(), tint: 0.4 }, + locked: false, + named: true, + hidden: false, + }); + d.swatches.push(Swatch::color("Unused Red", Color::cmyk(0.0, 1.0, 1.0, 0.0))); + let st = d.styles_mut(); + st.para_mut(BASIC).unwrap().chars.size = Some(11.0); + st.character.push(chars("Lead", None, 9.0)); + st.character.push(chars("Small", Some("Lead"), 7.0)); + let mut body = para("Body", Some(BASIC), 10.0); + body.chars.fill = Some("Brand Teal".into()); + body.para.grep_styles = Some(vec![GrepStyle { style: "Small".into(), pattern: r"\d+".into() }]); + st.paragraph.push(body); + let mut head = para("Head", Some("Body"), 20.0); + head.next_style = Some("Body".into()); + st.paragraph.push(head); + let mut quote = para("Quote", Some("Body"), 12.0); + quote.para.rule_above = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); + st.paragraph.push(quote); + Ok(()) + }) + .unwrap(); + s +} + +fn styles(s: &Session) -> Styles { + (*s.doc().unwrap().doc.styles).clone() +} + +#[test] +fn every_style_of_the_file_loads_with_the_swatches_it_uses() { + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + let d = &s.doc().unwrap().doc; + let head = d.styles.para("Head").unwrap(); + assert_eq!((head.based_on.as_deref(), head.next_style.as_deref(), head.chars.size), (Some("Body"), Some("Body"), Some(20.0))); + assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); + assert_eq!(d.styles.char_style("Small").unwrap().based_on.as_deref(), Some("Lead")); + assert_eq!(d.swatch("Brand Teal"), client().doc().unwrap().doc.swatch("Brand Teal")); + assert!(d.swatch("Brand Teal 40%").is_some()); + assert!(d.swatch("Unused Red").is_none(), "a swatch no loaded style uses stays behind"); + assert_eq!(r["paragraph"]["added"], json!(["Body", "Head", "Quote"])); + assert_eq!(r["character"]["added"], json!(["Lead", "Small"])); + assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); +} + +#[test] +fn basic_paragraph_takes_the_definition_of_the_file() { + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para(BASIC).unwrap().chars.size, Some(11.0)); + assert_eq!(r["paragraph"]["replaced"], json!([BASIC])); +} + +#[test] +fn a_named_style_brings_the_styles_it_names() { + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&client()), "paragraph": ["Head"], "character": []})).unwrap(); + let st = styles(&s); + assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"]), "Head is based on Body"); + assert_eq!(r["character"]["added"], json!(["Lead", "Small"]), "Body's GREP style names Small, which is based on Lead"); + assert!(st.para("Quote").is_none()); + assert_eq!(st.para(BASIC).unwrap().chars.size, None, "a style the document has is only replaced when it was asked for"); + assert_eq!(r["paragraph"]["replaced"], json!([])); + assert_eq!(r["swatches"], json!(["Brand Teal"])); +} + +/// The document already has a Body and a Lead of its own. +fn doc_with_body_and_lead() -> Session { + let mut s = new_doc(); + s.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("Body", None, 8.0)); + st.character.push(chars("Lead", None, 5.0)); + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn a_style_of_the_same_name_takes_the_loaded_definition() { + let mut s = doc_with_body_and_lead(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + let st = styles(&s); + assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(10.0), Some(9.0))); + assert_eq!(r["paragraph"]["replaced"], json!([BASIC, "Body"])); + assert_eq!(r["character"]["replaced"], json!(["Lead"])); + assert_eq!(st.paragraph.iter().filter(|p| p.name == "Body").count(), 1); +} + +#[test] +fn use_existing_keeps_the_styles_of_the_document() { + let mut s = doc_with_body_and_lead(); + let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "useExisting"})).unwrap(); + let st = styles(&s); + assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(8.0), Some(5.0))); + assert_eq!(st.para(BASIC).unwrap().chars.size, None); + assert_eq!(st.para("Head").unwrap().based_on.as_deref(), Some("Body")); + assert_eq!((&r["paragraph"]["added"], &r["paragraph"]["replaced"]), (&json!(["Head", "Quote"]), &json!([]))); +} + +#[test] +fn auto_rename_loads_a_clashing_style_under_a_new_name() { + let mut s = doc_with_body_and_lead(); + let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "autoRename"})).unwrap(); + let st = styles(&s); + assert_eq!((st.para("Body").unwrap().chars.size, st.para("Body 2").unwrap().chars.size), (Some(8.0), Some(10.0))); + assert_eq!((st.char_style("Lead").unwrap().chars.size, st.char_style("Lead 2").unwrap().chars.size), (Some(5.0), Some(9.0))); + let head = st.para("Head").unwrap(); + assert_eq!((head.based_on.as_deref(), head.next_style.as_deref()), (Some("Body 2"), Some("Body 2")), "the loaded Head follows the loaded Body"); + assert_eq!(st.char_style("Small").unwrap().based_on.as_deref(), Some("Lead 2")); + assert_eq!(st.para("Body 2").unwrap().based_on.as_deref(), Some("Basic Paragraph")); + assert_eq!(st.para("Basic Paragraph").unwrap().chars.size, Some(11.0), "a built-in style comes in without its brackets"); + assert_eq!(st.para(BASIC).unwrap().chars.size, None); + assert_eq!(r["paragraph"]["renamed"], json!({"[Basic Paragraph]": "Basic Paragraph", "Body": "Body 2"})); + assert_eq!(r["character"]["renamed"], json!({"Lead": "Lead 2"})); + assert_eq!(r["paragraph"]["replaced"], json!([])); +} + +#[test] +fn a_style_that_is_the_same_in_both_is_no_conflict() { + let mut s = new_doc(); + let file = file_of(&client()); + s.execute("style.load", &json!({"base64": file})).unwrap(); + let loaded = styles(&s); + for conflicts in ["redefine", "autoRename"] { + let r = s.execute("style.load", &json!({"base64": file, "conflicts": conflicts})).unwrap(); + let none = json!({"added": [], "replaced": [], "renamed": {}}); + assert_eq!(r, json!({"paragraph": none, "character": none, "swatches": []}), "{conflicts}"); + } + assert_eq!(styles(&s), loaded); + // Loading nothing leaves no undo step: one Undo takes back the first load. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(styles(&s).para("Head").is_none()); +} + +#[test] +fn loading_is_one_undo_step() { + let mut s = doc_with_body_and_lead(); + let (before, swatches) = (styles(&s), s.doc().unwrap().doc.swatches.clone()); + s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + assert_ne!(styles(&s), before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(styles(&s), before); + assert_eq!(s.doc().unwrap().doc.swatches, swatches); +} + +#[test] +fn a_swatch_of_the_document_wins_over_the_one_in_the_file() { + let mut s = new_doc(); + s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#ff0000"})).unwrap(); + let mine = s.doc().unwrap().doc.swatch("Brand Teal").cloned(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + assert_eq!(s.doc().unwrap().doc.swatch("Brand Teal").cloned(), mine); + assert_eq!(r["swatches"], json!(["Brand Teal 40%"])); +} + +#[test] +fn a_tint_swatch_brings_its_base_colour() { + let mut src = client(); + src.edit(|d, _| { + let mut ruled = para("Ruled", None, 9.0); + ruled.para.rule_below = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); + d.styles_mut().paragraph.push(ruled); + Ok(()) + }) + .unwrap(); + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Ruled"], "character": []})).unwrap(); + assert_eq!(r["paragraph"]["added"], json!(["Ruled"])); + assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); +} + +#[test] +fn export_tags_and_named_lists_come_with_their_styles() { + let mut src = client(); + src.edit(|d, _| { + d.settings.lists.push(NumberedList { name: "Steps".into(), continue_across_stories: false }); + d.settings.lists.push(NumberedList { name: "Unused".into(), ..Default::default() }); + let st = d.styles_mut(); + st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into(), split: true }); + st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new(), ..Default::default() }); + st.para_mut("Quote").unwrap().para.list_name = Some("Steps".into()); + Ok(()) + }) + .unwrap(); + let mut s = doc_with_body_and_lead(); + s.execute("style.load", &json!({"base64": file_of(&src), "conflicts": "autoRename"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.styles.export_tag("Head", false).map(|t| (t.tag.as_str(), t.split)), Some(("h1", true)), "Split Document comes along"); + assert_eq!(d.styles.export_tag("Lead 2", true).map(|t| t.tag.as_str()), Some("em"), "the tag follows the new name"); + assert_eq!(d.styles.export_tag("Lead", true), None); + assert_eq!(d.settings.lists.iter().map(|l| (l.name.as_str(), l.continue_across_stories)).collect::>(), [("Steps", false)]); +} + +#[test] +fn styles_load_from_an_idml_file() { + let idml = client().execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": idml, "name": "Client.IDML", "paragraph": ["Head"]})).unwrap(); + let d = &s.doc().unwrap().doc; + let head = d.styles.para("Head").unwrap(); + assert_eq!((head.based_on.as_deref(), head.chars.size), (Some("Body"), Some(20.0))); + assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); + assert!(d.swatch("Brand Teal").is_some()); + assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"])); +} + +#[test] +fn styles_load_from_a_path() { + let path = std::env::temp_dir().join(format!("dc-load-styles-{}.designcraft", std::process::id())); + std::fs::write(&path, super::super::file::to_bytes(&client().doc().unwrap().doc).unwrap()).unwrap(); + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"path": path.to_str().unwrap(), "paragraph": [], "character": ["Small"]})); + std::fs::remove_file(&path).unwrap(); + assert_eq!(r.unwrap()["character"]["added"], json!(["Lead", "Small"])); + assert!(styles(&s).para("Head").is_none()); +} + +#[test] +fn the_file_lists_its_styles_and_which_ones_clash() { + let mut s = doc_with_body_and_lead(); + let file = file_of(&client()); + let name = |n: &str, conflict: bool| json!({"name": n, "conflict": conflict}); + let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); + assert_eq!(r["paragraph"], json!([name(BASIC, true), name("Body", true), name("Head", false), name("Quote", false)])); + assert_eq!(r["character"], json!([name("Lead", true), name("Small", false)])); + s.execute("style.load", &json!({"base64": file})).unwrap(); + let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); + assert!(r["paragraph"].as_array().unwrap().iter().all(|p| p["conflict"] == false), "the same definition is no clash: {r}"); +} + +fn unchanged_after(s: &mut Session, params: Value, why: &str) { + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("style.load", ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn what_cannot_be_loaded_is_an_error_and_changes_nothing() { + let mut s = doc_with_body_and_lead(); + let file = file_of(&client()); + unchanged_after(&mut s, json!({}), "`path` or `base64`"); + unchanged_after(&mut s, json!({"base64": "AAAA"}), ""); + unchanged_after(&mut s, json!({"base64": "AAAA", "name": "a.idml"}), ""); + unchanged_after(&mut s, json!({"base64": file, "paragraph": ["Head", "Nope"]}), "no paragraph style `Nope`"); + unchanged_after(&mut s, json!({"base64": file, "character": ["[None]"]}), "no character style `[None]`"); + unchanged_after(&mut s, json!({"base64": file, "paragraph": ["[No Paragraph Style]"]}), "no paragraph style"); + unchanged_after(&mut s, json!({"base64": file, "paragraph": "Head"}), "`paragraph` must be a list of style names"); + unchanged_after(&mut s, json!({"base64": file, "character": [1]}), "`character` must be a list of style names"); + unchanged_after(&mut s, json!({"base64": file, "conflicts": "merge"}), "`conflicts`"); +} + +#[test] +fn a_style_that_would_be_based_on_itself_is_refused() { + // In the file B is based on A; in the document A is based on B. + let mut src = new_doc(); + src.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("A", None, 9.0)); + st.paragraph.push(para("B", Some("A"), 9.0)); + st.character.push(chars("X", None, 9.0)); + st.character.push(chars("Y", Some("X"), 9.0)); + Ok(()) + }) + .unwrap(); + let mut s = new_doc(); + s.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("B", None, 9.0)); + st.paragraph.push(para("A", Some("B"), 9.0)); + st.character.push(chars("Y", None, 9.0)); + st.character.push(chars("X", Some("Y"), 9.0)); + Ok(()) + }) + .unwrap(); + unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["B"], "character": []}), "`B` would be based on itself"); + unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": [], "character": ["Y"]}), "`Y` would be based on itself"); + // With A loaded as well both take the file's order. + s.execute("style.load", &json!({"base64": file_of(&src), "character": []})).unwrap(); + assert_eq!((styles(&s).para("A").unwrap().based_on.clone(), styles(&s).para("B").unwrap().based_on.clone()), (None, Some("A".to_string()))); +} + +#[test] +fn a_file_with_a_based_on_loop_of_its_own_does_not_hang() { + let mut src = new_doc(); + src.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("A", Some("B"), 9.0)); + st.paragraph.push(para("B", Some("A"), 9.0)); + st.paragraph.push(para("Next", None, 9.0)); + st.para_mut("Next").unwrap().next_style = Some("Next".into()); + Ok(()) + }) + .unwrap(); + let mut s = new_doc(); + unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["A"]}), "would be based on itself"); + let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Next"]})).unwrap(); + assert_eq!(r["paragraph"]["added"], json!(["Next"])); +} diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 3598c3c21d..a6946be4fb 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -490,482 +490,4 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn relocated_packages_rebind_embedded_image_links_on_open() { - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id())); - let original = dir.join("original"); - let moved = dir.join("moved"); - let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap(); - s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap(); - s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); - std::fs::rename(&original, &moved).unwrap(); - let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap(); - for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { - let mut opened = Session::new(); - opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); - let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); - assert_eq!(*asset.data, png, "{extension}: the packaged pixels"); - assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); - let links = opened.execute("links.list", &json!({})).unwrap(); - assert_eq!(links[0]["status"], "ok", "{extension}"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - /// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the - /// report says the copies differ instead of leaving it to be discovered. - #[test] - fn package_report_names_placed_pdf_layer_choices() { - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("dc-package-pdf-layers-{}-{nonce}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let pdf = super::super::pdflayers::tests::two_layer_pdf(); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "LayeredPackage", "width": 100, "height": 100})).unwrap(); - let id = s - .execute("file.place", &json!({"base64": super::super::base64_encode(&pdf), "name": "art.pdf", "x": 0, "y": 0, "width": 100})) - .unwrap()["id"] - .clone(); - s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); - let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); - let report = r["report"].as_str().unwrap(); - assert!(report.contains("Placed PDF layers"), "{report}"); - // The DesignCraft copy keeps the choice; only the linked IDML copy shows file defaults. - let packed = super::super::from_bytes(&std::fs::read(dir.join("LayeredPackage.designcraft")).unwrap()).unwrap(); - let item = packed.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); - assert_eq!(item.pdf_hidden_layers, vec!["Red".to_string()]); - assert!(item.has_pdf_layer_override()); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// The package's IDML links its images to the files in the package's Links folder and embeds - /// no copies of them. - #[test] - fn package_idml_links_the_links_folder_and_embeds_nothing() { - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("dc-package-idml-links-{}-{nonce}", std::process::id())); - let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "LinkedPackage", "width": 72, "height": 72})).unwrap(); - s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "my photo.png", "x": 5, "y": 5, "width": 16})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); - let idml = std::fs::read(dir.join("LinkedPackage.idml")).unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(idml.as_slice())).unwrap(); - let mut spreads = String::new(); - for i in 0..zip.len() { - let mut f = zip.by_index(i).unwrap(); - if f.name().starts_with("Spreads/") { - std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); - } - } - // Relative to the package: no path from the machine that made it (#340). - let uri = "file:Links/my%20photo.png"; - let folder = dir.file_name().unwrap().to_string_lossy().into_owned(); - assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}"); - assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); - assert!(!spreads.contains(""##; - let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg")); - std::fs::write(&gone, svg).unwrap(); - std::fs::write(&kept, svg.replace(""##).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); - let out = dir.join("Pkg"); - let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap(); - let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); - assert!(files.iter().any(|f| f.ends_with(".designcraft"))); - assert!(files.iter().any(|f| f.ends_with(".idml"))); - assert!(files.iter().any(|f| f.ends_with(".pdf"))); - assert!(out.join("Links").join("logo.svg").exists()); - let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); - assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); - assert!(report.contains("Preflight:"), "{report}"); - // The packaged document links to its Links folder. - let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); - let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); - let link = packed.assets.values().next().unwrap().link.clone().unwrap(); - assert!(link.contains("Links"), "{link}"); - // Copy Links To relinks the open document. - let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); - assert_eq!(r["copied"], 1); - assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_copies_the_fonts_their_licences_allow() { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - use designcraft_fonts::testing::{font_with, with_fs_type}; - const FREE: &str = "DocFont Package Free"; - const RESTRICTED: &str = "DocFont Package Restricted"; - let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); - std::fs::create_dir_all(&fonts).unwrap(); - std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); - std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap(); - } - // Text in both fonts, and in the bundled default font. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap(); - for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] { - s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap(); - s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); - } - let src = dir.join("src").join("Fonts.designcraft"); - s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); - let mut s = Session::new(); - assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2); - - let out = dir.join("Pkg"); - let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); - let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); - assert!(packed_fonts.join("free.ttf").exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - assert_eq!( - std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777, - std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777 - ); - } - assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); - assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); - let report = r["report"].as_str().unwrap(); - assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); - assert!(report.contains(&format!("{FREE} Regular\n")), "{report}"); - assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}"); - // The packaged document opens with its fonts. - let doc = r["files"][0].as_str().unwrap().to_string(); - assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1); - // Packaging into the folder the fonts come from leaves them whole. - let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len(); - s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap(); - assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_copies_the_fallback_fonts_that_draw_text() { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - use designcraft_fonts::testing::{font_with, with_fs_type}; - const NAMED: &str = "DocFont Fallback Named"; - const HELPER: &str = "DocFont Fallback Helper"; - const RESTRICTED: &str = "DocFont Fallback Restricted"; - // Characters no other font has: the named font lacks both, the document's other fonts - // draw them. - let (helped, restricted) = ('\u{F0A41}', '\u{F0A42}'); - let dir = std::env::temp_dir().join(format!("dc-package-fallback-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); - std::fs::create_dir_all(&fonts).unwrap(); - std::fs::write(fonts.join("named.ttf"), font_with(NAMED, &['N']).unwrap()).unwrap(); - std::fs::write(fonts.join("helper.ttf"), font_with(HELPER, &[helped]).unwrap()).unwrap(); - std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &[restricted]).unwrap(), 0x0002).unwrap()).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); - let text = format!("N{helped}{restricted}"); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); - s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap(); - let src = dir.join("src").join("Fallback.designcraft"); - // Written without File › Save's preview: drawing it before the document's fonts load would - // pull in a system font that covers these characters (Unifont CSUR does) and draws them - // ahead of the document's own fonts in this process. - std::fs::write(&src, crate::cmd::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); - let mut s = Session::new(); - assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3); - - let out = dir.join("Pkg"); - let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); - let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); - assert!(packed_fonts.join("named.ttf").exists()); - assert!(packed_fonts.join("helper.ttf").exists(), "the fallback font that draws text is copied"); - assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps a fallback font out too"); - let report = r["report"].as_str().unwrap(); - let fallback = report.split("Fallback fonts").nth(1).unwrap_or_else(|| panic!("{report}")); - assert!(fallback.contains(&format!("{HELPER} Regular\n")), "{report}"); - assert!(fallback.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); - assert!(!fallback.contains(NAMED), "{report}"); - // The packaged document draws the same text with its fonts. - let doc = r["files"][0].as_str().unwrap().to_string(); - assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn packaged_fonts_keep_a_font_extension() { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - use designcraft_fonts::testing::{font_with, installed_face}; - // A font service's file without an extension is copied under a name the reopened - // package loads. - let dir = std::env::temp_dir().join(format!("dc-package-font-ext-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let from = dir.join(".12345"); - let bytes = font_with("DocFont Package Extensionless", &['E']).unwrap(); - std::fs::write(&from, &bytes).unwrap(); - let face = installed_face(bytes, from).unwrap(); - let root = OutputRoot::new(dir.clone()).unwrap(); - let fonts = dir.join("Pkg").join(DOCUMENT_FONTS_FOLDER); - std::fs::create_dir_all(&fonts).unwrap(); - let (mut copied, mut taken, mut files) = (Vec::new(), Vec::new(), Vec::new()); - assert_eq!(copy_font(&root, &face, &fonts, &mut copied, &mut taken, &mut files), ""); - assert_eq!(files, [fonts.join("12345.ttf")]); - let db = designcraft_fonts::FontDb::global(); - let loaded = db.load_document_fonts(&fonts, &|_| Ok(())); - assert_eq!((loaded.faces, loaded.skipped.len()), (1, 0), "{:?}", loaded.skipped); - db.close_scope(loaded.scope); - // The first four bytes name the kind; a font extension or an unknown kind stays. - assert_eq!(font_file_name("Face".into(), b"OTTO...."), "Face.otf"); - assert_eq!(font_file_name("Faces".into(), b"ttcf...."), "Faces.ttc"); - assert_eq!(font_file_name("Mac".into(), b"true...."), "Mac.ttf"); - assert_eq!(font_file_name("..".into(), &[0, 1, 0, 0]), "font.ttf"); - assert_eq!(font_file_name("Face.OTF".into(), b"ttcf"), "Face.OTF"); - assert_eq!(font_file_name("Face.dfont".into(), b"\0\0\x01\0"), "Face.dfont"); - assert_eq!(font_file_name("short".into(), b"OT"), "short"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_uses_portable_names_inside_the_selected_directory() { - assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review"); - assert_eq!(safe_component(".", "Untitled"), "Untitled"); - assert_eq!(safe_component("CON", "Untitled"), "_CON"); - assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt"); - assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³"); - for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] { - assert!(is_reserved_windows_name(name), "{name}"); - } - let mut taken = Vec::new(); - assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg"); - assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg"); - assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf"); - assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf"); - assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt"); - assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt"); - - let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap(); - let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - let files = r["files"].as_array().unwrap(); - assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir))); - assert!(dir.join("Annual_ Review.designcraft").is_file()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_overwrites_existing_regular_outputs() { - let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let document = dir.join("Annual.designcraft"); - std::fs::write(&document, b"previous package").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap(); - } - - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Annual"})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok()); - assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_accepts_a_relative_destination() { - let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Relative"})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - assert!(dir.join("Relative.designcraft").is_file()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(unix)] - #[test] - fn package_refuses_redirected_destinations() { - use std::os::unix::fs::symlink; - - let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base).unwrap(); - let outside = base.join("separate"); - std::fs::create_dir_all(&outside).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Annual"})).unwrap(); - - let redirected_root = base.join("redirected-package"); - symlink(&outside, &redirected_root).unwrap(); - assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err()); - assert!(!outside.join("Annual.designcraft").exists()); - std::fs::remove_file(&redirected_root).unwrap(); - - // A linked folder above the package is followed, as `/tmp` is on macOS. - let linked_parent = base.join("linked-parent"); - symlink(&outside, &linked_parent).unwrap(); - let nested_package = linked_parent.join("nested").join("Package"); - s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap(); - assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file()); - std::fs::remove_file(&linked_parent).unwrap(); - - let package = base.join("Package"); - std::fs::create_dir_all(&package).unwrap(); - symlink(&outside, package.join("Links")).unwrap(); - assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); - assert!(!outside.join("Annual.designcraft").exists()); - std::fs::remove_file(package.join("Links")).unwrap(); - std::fs::create_dir(package.join("Links")).unwrap(); - - let kept = outside.join("kept.designcraft"); - std::fs::write(&kept, b"kept").unwrap(); - symlink(&kept, package.join("Annual.designcraft")).unwrap(); - assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); - assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); - let _ = std::fs::remove_dir_all(&base); - } - - #[cfg(windows)] - #[test] - fn package_refuses_windows_directory_redirects() { - use std::os::windows::fs::symlink_dir; - - let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let outside = base.join("separate"); - std::fs::create_dir_all(&outside).unwrap(); - let redirected = base.join("Package"); - // A symbolic link needs Developer Mode or elevation (os error 1314 without them); a - // directory junction needs neither, and `is_redirect` treats both as redirects. - if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) { - eprintln!("skipped: directory redirects can't be made here"); - let _ = std::fs::remove_dir_all(&base); - return; - } - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Annual"})).unwrap(); - assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err()); - assert!(!outside.join("Annual.designcraft").exists()); - std::fs::remove_dir(&redirected).unwrap(); - let _ = std::fs::remove_dir_all(&base); - } - - /// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made. - #[cfg(windows)] - fn make_junction(target: &Path, link: &Path) -> bool { - use std::os::windows::process::CommandExt; - - // Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd. - std::process::Command::new("cmd") - .args(["/D", "/C"]) - .raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display())) - .output() - .is_ok_and(|output| output.status.success()) - } -} +mod tests; diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs new file mode 100644 index 0000000000..acc6d657f0 --- /dev/null +++ b/crates/engine/src/cmd/package/tests.rs @@ -0,0 +1,476 @@ +use super::*; + +#[test] +fn relocated_packages_rebind_embedded_image_links_on_open() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id())); + let original = dir.join("original"); + let moved = dir.join("moved"); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap(); + s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + std::fs::rename(&original, &moved).unwrap(); + let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap(); + for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { + let mut opened = Session::new(); + opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); + let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); + assert_eq!(*asset.data, png, "{extension}: the packaged pixels"); + assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); + let links = opened.execute("links.list", &json!({})).unwrap(); + assert_eq!(links[0]["status"], "ok", "{extension}"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the +/// report says the copies differ instead of leaving it to be discovered. +#[test] +fn package_report_names_placed_pdf_layer_choices() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-layers-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pdf = super::super::pdflayers::tests::two_layer_pdf(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "LayeredPackage", "width": 100, "height": 100})).unwrap(); + let id = s.execute("file.place", &json!({"base64": super::super::base64_encode(&pdf), "name": "art.pdf", "x": 0, "y": 0, "width": 100})).unwrap() + ["id"] + .clone(); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + let report = r["report"].as_str().unwrap(); + assert!(report.contains("Placed PDF layers"), "{report}"); + // The DesignCraft copy keeps the choice; only the linked IDML copy shows file defaults. + let packed = super::super::from_bytes(&std::fs::read(dir.join("LayeredPackage.designcraft")).unwrap()).unwrap(); + let item = packed.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); + assert_eq!(item.pdf_hidden_layers, vec!["Red".to_string()]); + assert!(item.has_pdf_layer_override()); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The package's IDML links its images to the files in the package's Links folder and embeds +/// no copies of them. +#[test] +fn package_idml_links_the_links_folder_and_embeds_nothing() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-idml-links-{}-{nonce}", std::process::id())); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "LinkedPackage", "width": 72, "height": 72})).unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "my photo.png", "x": 5, "y": 5, "width": 16})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + let idml = std::fs::read(dir.join("LinkedPackage.idml")).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(idml.as_slice())).unwrap(); + let mut spreads = String::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + if f.name().starts_with("Spreads/") { + std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); + } + } + // Relative to the package: no path from the machine that made it (#340). + let uri = "file:Links/my%20photo.png"; + let folder = dir.file_name().unwrap().to_string_lossy().into_owned(); + assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}"); + assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); + assert!(!spreads.contains(""##; + let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg")); + std::fs::write(&gone, svg).unwrap(); + std::fs::write(&kept, svg.replace(""##).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap(); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert!(files.iter().any(|f| f.ends_with(".designcraft"))); + assert!(files.iter().any(|f| f.ends_with(".idml"))); + assert!(files.iter().any(|f| f.ends_with(".pdf"))); + assert!(out.join("Links").join("logo.svg").exists()); + let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); + assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); + assert!(report.contains("Preflight:"), "{report}"); + // The packaged document links to its Links folder. + let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); + let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); + let link = packed.assets.values().next().unwrap().link.clone().unwrap(); + assert!(link.contains("Links"), "{link}"); + // Copy Links To relinks the open document. + let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); + assert_eq!(r["copied"], 1); + assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_copies_the_fonts_their_licences_allow() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, with_fs_type}; + const FREE: &str = "DocFont Package Free"; + const RESTRICTED: &str = "DocFont Package Restricted"; + let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); + std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap(); + } + // Text in both fonts, and in the bundled default font. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap(); + for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] { + s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + } + let src = dir.join("src").join("Fonts.designcraft"); + s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2); + + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); + let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); + assert!(packed_fonts.join("free.ttf").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777, + std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777 + ); + } + assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); + assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); + let report = r["report"].as_str().unwrap(); + assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); + assert!(report.contains(&format!("{FREE} Regular\n")), "{report}"); + assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}"); + // The packaged document opens with its fonts. + let doc = r["files"][0].as_str().unwrap().to_string(); + assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1); + // Packaging into the folder the fonts come from leaves them whole. + let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len(); + s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap(); + assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_copies_the_fallback_fonts_that_draw_text() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, with_fs_type}; + const NAMED: &str = "DocFont Fallback Named"; + const HELPER: &str = "DocFont Fallback Helper"; + const RESTRICTED: &str = "DocFont Fallback Restricted"; + // Characters no other font has: the named font lacks both, the document's other fonts + // draw them. + let (helped, restricted) = ('\u{F0A41}', '\u{F0A42}'); + let dir = std::env::temp_dir().join(format!("dc-package-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("named.ttf"), font_with(NAMED, &['N']).unwrap()).unwrap(); + std::fs::write(fonts.join("helper.ttf"), font_with(HELPER, &[helped]).unwrap()).unwrap(); + std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &[restricted]).unwrap(), 0x0002).unwrap()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); + let text = format!("N{helped}{restricted}"); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap(); + let src = dir.join("src").join("Fallback.designcraft"); + // Written without File › Save's preview: drawing it before the document's fonts load would + // pull in a system font that covers these characters (Unifont CSUR does) and draws them + // ahead of the document's own fonts in this process. + std::fs::write(&src, crate::cmd::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); + let mut s = Session::new(); + assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3); + + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); + let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); + assert!(packed_fonts.join("named.ttf").exists()); + assert!(packed_fonts.join("helper.ttf").exists(), "the fallback font that draws text is copied"); + assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps a fallback font out too"); + let report = r["report"].as_str().unwrap(); + let fallback = report.split("Fallback fonts").nth(1).unwrap_or_else(|| panic!("{report}")); + assert!(fallback.contains(&format!("{HELPER} Regular\n")), "{report}"); + assert!(fallback.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); + assert!(!fallback.contains(NAMED), "{report}"); + // The packaged document draws the same text with its fonts. + let doc = r["files"][0].as_str().unwrap().to_string(); + assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn packaged_fonts_keep_a_font_extension() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, installed_face}; + // A font service's file without an extension is copied under a name the reopened + // package loads. + let dir = std::env::temp_dir().join(format!("dc-package-font-ext-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let from = dir.join(".12345"); + let bytes = font_with("DocFont Package Extensionless", &['E']).unwrap(); + std::fs::write(&from, &bytes).unwrap(); + let face = installed_face(bytes, from).unwrap(); + let root = OutputRoot::new(dir.clone()).unwrap(); + let fonts = dir.join("Pkg").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + let (mut copied, mut taken, mut files) = (Vec::new(), Vec::new(), Vec::new()); + assert_eq!(copy_font(&root, &face, &fonts, &mut copied, &mut taken, &mut files), ""); + assert_eq!(files, [fonts.join("12345.ttf")]); + let db = designcraft_fonts::FontDb::global(); + let loaded = db.load_document_fonts(&fonts, &|_| Ok(())); + assert_eq!((loaded.faces, loaded.skipped.len()), (1, 0), "{:?}", loaded.skipped); + db.close_scope(loaded.scope); + // The first four bytes name the kind; a font extension or an unknown kind stays. + assert_eq!(font_file_name("Face".into(), b"OTTO...."), "Face.otf"); + assert_eq!(font_file_name("Faces".into(), b"ttcf...."), "Faces.ttc"); + assert_eq!(font_file_name("Mac".into(), b"true...."), "Mac.ttf"); + assert_eq!(font_file_name("..".into(), &[0, 1, 0, 0]), "font.ttf"); + assert_eq!(font_file_name("Face.OTF".into(), b"ttcf"), "Face.OTF"); + assert_eq!(font_file_name("Face.dfont".into(), b"\0\0\x01\0"), "Face.dfont"); + assert_eq!(font_file_name("short".into(), b"OT"), "short"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_uses_portable_names_inside_the_selected_directory() { + assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review"); + assert_eq!(safe_component(".", "Untitled"), "Untitled"); + assert_eq!(safe_component("CON", "Untitled"), "_CON"); + assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt"); + assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³"); + for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] { + assert!(is_reserved_windows_name(name), "{name}"); + } + let mut taken = Vec::new(); + assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg"); + assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg"); + assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf"); + assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf"); + assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt"); + assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt"); + + let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap(); + let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir))); + assert!(dir.join("Annual_ Review.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_overwrites_existing_regular_outputs() { + let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let document = dir.join("Annual.designcraft"); + std::fs::write(&document, b"previous package").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap(); + } + + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok()); + assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_accepts_a_relative_destination() { + let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Relative"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(dir.join("Relative.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn package_refuses_redirected_destinations() { + use std::os::unix::fs::symlink; + + let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + + let redirected_root = base.join("redirected-package"); + symlink(&outside, &redirected_root).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(&redirected_root).unwrap(); + + // A linked folder above the package is followed, as `/tmp` is on macOS. + let linked_parent = base.join("linked-parent"); + symlink(&outside, &linked_parent).unwrap(); + let nested_package = linked_parent.join("nested").join("Package"); + s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap(); + assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file()); + std::fs::remove_file(&linked_parent).unwrap(); + + let package = base.join("Package"); + std::fs::create_dir_all(&package).unwrap(); + symlink(&outside, package.join("Links")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(package.join("Links")).unwrap(); + std::fs::create_dir(package.join("Links")).unwrap(); + + let kept = outside.join("kept.designcraft"); + std::fs::write(&kept, b"kept").unwrap(); + symlink(&kept, package.join("Annual.designcraft")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); + let _ = std::fs::remove_dir_all(&base); +} + +#[cfg(windows)] +#[test] +fn package_refuses_windows_directory_redirects() { + use std::os::windows::fs::symlink_dir; + + let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let redirected = base.join("Package"); + // A symbolic link needs Developer Mode or elevation (os error 1314 without them); a + // directory junction needs neither, and `is_redirect` treats both as redirects. + if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) { + eprintln!("skipped: directory redirects can't be made here"); + let _ = std::fs::remove_dir_all(&base); + return; + } + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_dir(&redirected).unwrap(); + let _ = std::fs::remove_dir_all(&base); +} + +/// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made. +#[cfg(windows)] +fn make_junction(target: &Path, link: &Path) -> bool { + use std::os::windows::process::CommandExt; + + // Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd. + std::process::Command::new("cmd") + .args(["/D", "/C"]) + .raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display())) + .output() + .is_ok_and(|output| output.status.success()) +} diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 16554ed238..75e573b568 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -84,554 +84,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -pub(crate) mod tests { - use serde_json::json; - - use crate::Session; - - /// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). - pub(crate) fn two_layer_pdf() -> Vec { - two_layer_pdf_generation(0) - } - - fn two_layer_pdf_generation(generation: i32) -> Vec { - two_layer_pdf_options(generation, "", "") - } - - fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec { - let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; - let objs = [ - format!( - "<>>>>>" - ), - "<>".to_string(), - "<>>>/Contents 4 0 R>>".to_string(), - format!("<>\nstream\n{content}\nendstream", content.len()), - "<>".to_string(), - "<>".to_string(), - "<>\nstream\n\nendstream".to_string(), - "<>".to_string(), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes()); - } - let x = out.len(); - let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); - for o in offs { - t.push_str(&format!("{o:010} {generation:05} n\r\n")); - } - t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); - out.extend_from_slice(t.as_bytes()); - out - } - - #[test] - fn pdf_layer_update_preserves_catalog_configuration_and_generations() { - for generation in [0, 1] { - let original = two_layer_pdf_generation(generation); - let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap(); - assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); - let appended = std::str::from_utf8(&updated[original.len()..]).unwrap(); - for token in [ - "/Lang(en-GB)".to_string(), - format!("/Metadata 7 {generation} R"), - format!("/OutputIntents[8 {generation} R]"), - format!("/Locked[5 {generation} R]"), - "/Name(Default)".into(), - format!("/Root 1 {generation} R"), - ] { - assert!(appended.contains(&token), "missing {token}: {appended}"); - } - assert!(!appended.contains("9999000")); - let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap(); - assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible)); - } - } - - #[test] - fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() { - let original = two_layer_pdf(); - let text = std::str::from_utf8(&original).unwrap(); - let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); - let mut stream_pdf = original[..offset].to_vec(); - let mut entries = vec![0, 0, 0, 0, 0, 255, 255]; - for number in 1..=9 { - let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() }; - entries.push(1); - entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes()); - entries.extend_from_slice(&[0, 0]); - } - stream_pdf.extend_from_slice(format!("9 0 obj\n<>\nstream\n", entries.len()).as_bytes()); - stream_pdf.extend_from_slice(&entries); - stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); - let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap(); - assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); - for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] { - let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9")); - assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none()); - } - } - - #[test] - fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() { - let original = two_layer_pdf(); - let text = std::str::from_utf8(&original).unwrap(); - let first = original.len(); - let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<>\n"); - let main = first + link(0).len(); - let mut forward = original.clone(); - forward.extend_from_slice(link(main).as_bytes()); - let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); - forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes()); - forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); - let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap(); - assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); - let mut cyclic = original; - cyclic.extend_from_slice(link(first).as_bytes()); - cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); - assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none()); - } - - fn add_pdf_revision(bytes: &mut Vec) { - let text = std::str::from_utf8(bytes).unwrap(); - let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); - let xref = bytes.len(); - bytes.extend_from_slice( - format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), - ); - } - - fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value { - s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100})) - .unwrap()["id"] - .clone() - } - - fn pixel(s: &Session, x: u32) -> [u8; 4] { - let mut renderer = designcraft_render::Renderer::new(); - renderer.threads = 0; - renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50) - } - - #[test] - fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() { - for generation in [0, 1] { - let bytes = two_layer_pdf_options(generation, "", "/Encrypt null"); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); - let id = place(&mut s, &bytes, 0.0); - s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); - assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); - assert_eq!(pixel(&s, 25), [0, 0, 255, 255]); - assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); - s.execute("file.exportPdf", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); - } - } - - #[test] - fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() { - let mut unsupported = two_layer_pdf(); - for _ in 0..32 { - add_pdf_revision(&mut unsupported); - } - assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); - assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none()); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); - let good = place(&mut s, &two_layer_pdf(), 0.0); - let bad = place(&mut s, &unsupported, 100.0); - s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!s.doc().unwrap().history.redo.is_empty()); - let before = s.execute("file.serialize", &json!({})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let redo = s.doc().unwrap().history.redo.len(); - for ids in [json!([good, bad]), json!([bad, good])] { - let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err(); - assert!(err.to_string().contains("could not be changed")); - assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - assert_eq!(s.doc().unwrap().history.redo.len(), redo); - for id in [&good, &bad] { - assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true); - } - } - assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); - assert_eq!(pixel(&s, 175), [255, 0, 0, 255]); - // A persisted/relinked choice can be unrewritable. Reset must still work. - let bad_id = serde_json::from_value(bad.clone()).unwrap(); - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()]; - assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas"); - assert!(s.execute("file.exportPdf", &json!({})).is_err()); - s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap(); - assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true); - s.execute("file.exportPdf", &json!({})).unwrap(); - } - - #[test] - fn explicit_all_on_validates_an_actual_rewrite_before_editing() { - // 33 xref sections: the layers stay readable, but the visibility rewrite refuses the history. - let mut unsupported = two_layer_pdf(); - for _ in 0..32 { - add_pdf_revision(&mut unsupported); - } - assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); - assert!(designcraft_render::pdf_hide_layers(&unsupported, &[]).is_none()); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); - let good = place(&mut s, &two_layer_pdf(), 0.0); - let bad = place(&mut s, &unsupported, 100.0); - let before = s.execute("file.serialize", &json!({})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - for ids in [json!([good, bad]), json!([bad, good]), json!([bad])] { - let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": [], "useDefaults": false})).unwrap_err(); - assert!(err.to_string().contains("could not be changed"), "{err}"); - assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before, "rejected all-on must not edit any target"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - } - assert_eq!(pixel(&s, 175), [255, 0, 0, 255], "the unsupported source keeps showing its file defaults"); - s.execute("file.exportPdf", &json!({})).unwrap(); - // The supported source alone still accepts the explicit all-on choice. - s.execute("object.layerOptions", &json!({"ids": [good], "hidden": [], "useDefaults": false})).unwrap(); - assert_eq!(s.execute("object.pdfLayers", &json!({"id": good})).unwrap()[1]["overridden"], true); - // Resetting to file defaults needs no variant, so it bypasses the rewrite check. - for reset in [json!({"ids": [good, bad], "hidden": []}), json!({"ids": [good, bad], "useDefaults": true})] { - s.execute("object.layerOptions", &reset).unwrap(); - for id in [&good, &bad] { - assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["overridden"], false); - } - } - // A stale explicit all-on choice that outlived its rewritable source still fails closed. - let bad_id = serde_json::from_value(bad.clone()).unwrap(); - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_layer_override = true; - assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "unrewritable explicit choice must not draw the unmodified source"); - assert!(s.execute("file.exportPdf", &json!({})).is_err()); - } - - #[test] - fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { - for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { - let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}")); - assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}"); - assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}"); - } - for trailer in ["", "/Prev null"] { - let bytes = two_layer_pdf_options(0, "", trailer); - assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}"); - } - let mut revised = two_layer_pdf(); - let prev = - std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::().unwrap(); - add_pdf_revision(&mut revised); - let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>")); - assert!(real_prev.contains(&format!("/Prev {prev}.0>>"))); - for bytes in [revised, real_prev.into_bytes()] { - assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work"); - } - } - - #[test] - fn fake_null_trailer_cannot_bypass_hybrid_rejection() { - for real_guard in ["", "/XRefStm 0"] { - let bytes = two_layer_pdf_options(0, "", real_guard); - let text = std::str::from_utf8(&bytes).unwrap(); - for fake in ["/Encrypt null", ""] { - let comment = format!("% trailer <>\n"); - let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1); - assert!(inside.contains(&format!("xref\n{comment}"))); - // Repair-only comments inside a table are always refused, guard or not. - assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2); - assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none()); - // Before xref or after the dictionary, the real trailer decides. - let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1); - let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1); - for source in [leading, trailing] { - assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2); - assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty()); - } - } - } - } - - #[test] - fn content_all_still_measures_layerless_and_stub_properties() { - use designcraft_doc::PdfCrop; - for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<>>>"] { - let content = "0 0 1 rg 0 0 50 100 re f"; - let objs = [ - format!("<>"), - "<>".into(), - "<>".into(), - format!("<>\nstream\n{content}\nendstream", content.len()), - ]; - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, body) in objs.iter().enumerate() { - offsets.push(bytes.len()); - bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); - } - let xref = bytes.len(); - bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); - for at in offsets { - bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes()); - } - bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - assert!(designcraft_render::pdf_layers(&bytes).is_empty()); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}"); - } - } - - #[test] - fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() { - use designcraft_doc::PdfCrop; - let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", ""); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0)); - for _ in 0..32 { - add_pdf_revision(&mut bytes); - } - assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none()); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0))); - } - - #[test] - fn placed_pdf_layers_hide() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let pdf = two_layer_pdf(); - let r = s - .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "art.pdf", "x": 100, "y": 100, "width": 100})) - .unwrap(); - let id = r["id"].clone(); - let l = s.execute("object.pdfLayers", &json!({"id": id})).unwrap(); - assert_eq!(l.as_array().unwrap().len(), 2); - assert_eq!(l[0]["name"], "Blue"); - let px = |s: &Session, x: u32| { - let d = s.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 150) - }; - assert!(px(&s, 125)[2] > 200 && px(&s, 175)[0] > 200, "both layers show"); - s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); - let right = px(&s, 175); - assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}"); - assert!(px(&s, 125)[2] > 200, "blue still shows"); - assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); - let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - let image = designcraft_render::render_pdf_page(&bytes, 0, 792).unwrap(); - let width = usize::from(image.width()); - assert!(image.data()[150 * width + 125].a > 200); - assert_eq!(image.data()[150 * width + 175].a, 0); - } - #[test] - fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() { - let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s - .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100})) - .unwrap()["id"] - .clone(); - let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap(); - let pixel = |s: &Session| { - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150) - }; - assert!(!visible(&mut s)); - assert!(pixel(&s)[1] > 200, "red hidden by default"); - s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap(); - assert!(visible(&mut s)); - assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red"); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!visible(&mut s)); - s.execute("edit.redo", &json!({})).unwrap(); - assert!(visible(&mut s)); - let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap(); - let roundtrip = designcraft_format::load(&bytes).unwrap(); - let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); - assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty()); - let exported = s.execute("file.exportPdf", &json!({})).unwrap(); - let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap()); - let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap(); - let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap(); - assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}"); - let idml = s.execute("file.exportIdml", &json!({})).unwrap(); - let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap()); - let imported = designcraft_idml::import_idml(&bytes).unwrap(); - let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); - assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on)); - assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err()); - assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf); - s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap(); - assert!(!visible(&mut s), "legacy empty list still resets defaults"); - s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap(); - s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap(); - assert!(!visible(&mut s), "explicit reset restores defaults"); - } - #[test] - fn parent_page_choices_reach_idml_variants_and_raster_export() { - // Red is hidden by the file itself, so "show every layer" is a visible difference. - let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); - let id = s - .execute( - "file.place", - &json!({"base64": super::super::file::base64_encode(&pdf), "name": "on-parent.pdf", "spread": {"kind": "parent", "index": 0}, "x": 0.0, "y": 0.0, "width": 100}), - ) - .unwrap()["id"] - .clone(); - let item = designcraft_doc::ItemId(id.as_u64().unwrap()); - assert!( - matches!(s.doc().unwrap().doc.find(item).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)), - "the placement sits on a parent page" - ); - s.execute("object.layerOptions", &json!({"ids": [id], "hidden": [], "useDefaults": false})).unwrap(); - // Embedded IDML: the parent-page placement gets its own variant, like a document one. - let idml = s.execute("file.exportIdml", &json!({})).unwrap(); - let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap(); - let asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); - assert!(designcraft_render::pdf_layers(&asset.data).iter().all(|(_, on)| *on), "the embedded copy shows every layer"); - // An external-only copy would keep the original file's defaults: the choice is rejected. - let err = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string(); - assert!(err.contains("embedded images"), "{err}"); - // PDF/A-2b rasterises a parent-page placement with a choice, warnings included. - let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); - assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}"); - } - - /// An object anchored in a story is a placement too: the IDML copy gets a variant that shows - /// every layer, an external-only copy is refused, and PDF/A-2b rasterises it, rather than each - /// quietly falling back to the file's defaults. - #[test] - fn an_anchored_placement_keeps_its_layer_choice_in_saves_and_exports() { - use designcraft_doc::{AnchoredObject, Content, Graphic, Item, Shape, SpreadRef}; - use designcraft_geom::{Affine, Rect, shapes}; - - let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); - let mut d = designcraft_doc::Document::new(&Default::default()); - let asset = designcraft_doc::AssetId(d.alloc()); - d.add_asset(designcraft_doc::Asset { - id: asset, - name: "anchored.pdf".into(), - mime: "application/pdf".into(), - data: std::sync::Arc::new(pdf), - ..Default::default() - }); - let mut item = - Item::new(designcraft_doc::ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); - item.pdf_layer_override = true; - item.content = Content::Graphic(Graphic { - asset, - size: (100.0, 100.0), - xf: Affine::IDENTITY, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - layer_options: None, - }); - let layer = d.default_layer(); - let (_, story) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); - d.story_mut(story).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() }); - assert!(d.has_pdf_layer_overrides(), "the anchored placement carries the choice"); - let mut s = Session::new(); - s.add_document(crate::DocState::new(d, None)); - // Embedded IDML carries a variant that shows every layer. - let idml = s.execute("file.exportIdml", &json!({})).unwrap(); - let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap(); - let copies: Vec<_> = imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).collect(); - assert!(!copies.is_empty(), "the anchored placement is exported"); - assert!( - copies.iter().all(|a| designcraft_render::pdf_layers(&a.data).iter().all(|(_, on)| *on)), - "the embedded copy must not fall back to the file's defaults" - ); - // An external-only copy would keep the original file's defaults. - assert!(s.execute("file.exportIdml", &json!({"embedImages": false})).is_err()); - // PDF/A-2b rasterises a placement with a choice, warnings included. - let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); - assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}"); - } - - /// Relinking a placed PDF to an image leaves the item's layer fields behind. They are no longer a - /// placed-PDF choice: the package report asks for a graphic whose asset is a PDF, the same thing - /// the export walks ask for, so it does not name the stale field, and exports run unaffected. - #[test] - fn a_stale_choice_on_a_non_pdf_placement_is_not_a_placed_pdf_choice() { - let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = s - .execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "photo.png", "x": 0, "y": 0, "width": 100})) - .unwrap()["id"] - .clone(); - let item = designcraft_doc::ItemId(id.as_u64().unwrap()); - // The state a relink from a PDF leaves behind: fields set, asset no longer a PDF. - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(item).unwrap().pdf_hidden_layers = vec!["Red".into()]; - let d = &s.doc().unwrap().doc; - assert!(d.item(item).unwrap().has_pdf_layer_override(), "the stale field is on the item"); - assert!(d.has_pdf_layer_overrides(), "the field is there"); - assert!(!super::super::interchange::has_applicable_pdf_layer_overrides(d), "but it is not a placed-PDF choice"); - // Nothing is dropped: the field is not a choice any export applies. - s.execute("file.exportIdml", &json!({})).unwrap(); - s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); - s.execute("file.exportPdf", &json!({})).unwrap(); - } - - #[test] - fn layerless_pdf_rejects_overrides_without_breaking_idml_export() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let pdf = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); - let id = s.execute("file.place", &json!({"base64":pdf,"name":"plain.pdf","x":0,"y":0,"width":100})).unwrap()["id"].clone(); - let layered = s - .execute( - "file.place", - &json!({"base64":super::super::file::base64_encode(&two_layer_pdf()),"name":"layered.pdf","x":120,"y":0,"width":100}), - ) - .unwrap()["id"] - .clone(); - let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); - assert!(s.execute("object.layerOptions", &json!({"ids":[layered,id],"hidden":[],"useDefaults":false})).is_err()); - assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before); - assert!(s.execute("file.exportIdml", &json!({})).is_ok()); - s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":true})).unwrap(); - assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override()); - } - - #[test] - fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let placed = s - .execute( - "file.place", - &json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100}), - ) - .unwrap(); - let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap(); - for ids in [json!([other["id"], placed["id"]]), json!([placed["id"], other["id"]])] { - s.execute("selection.set", &json!({"ids":ids})).unwrap(); - assert_eq!(s.execute("object.pdfLayers", &json!({})).unwrap().as_array().unwrap().len(), 2); - } - assert!(s.execute("object.pdfLayers", &json!({"id":other["id"]})).is_err(), "explicit non-PDF id remains an error"); - s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap(); - assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override()); - assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err()); - assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err()); - } -} +pub(crate) mod tests; diff --git a/crates/engine/src/cmd/pdflayers/tests.rs b/crates/engine/src/cmd/pdflayers/tests.rs new file mode 100644 index 0000000000..8612303e2b --- /dev/null +++ b/crates/engine/src/cmd/pdflayers/tests.rs @@ -0,0 +1,540 @@ +use serde_json::json; + +use crate::Session; + +/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). +pub(crate) fn two_layer_pdf() -> Vec { + two_layer_pdf_generation(0) +} + +fn two_layer_pdf_generation(generation: i32) -> Vec { + two_layer_pdf_options(generation, "", "") +} + +fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec { + let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; + let objs = [ + format!( + "<>>>>>" + ), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".to_string(), + "<>".to_string(), + "<>\nstream\n\nendstream".to_string(), + "<>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes()); + } + let x = out.len(); + let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); + for o in offs { + t.push_str(&format!("{o:010} {generation:05} n\r\n")); + } + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + out.extend_from_slice(t.as_bytes()); + out +} + +#[test] +fn pdf_layer_update_preserves_catalog_configuration_and_generations() { + for generation in [0, 1] { + let original = two_layer_pdf_generation(generation); + let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let appended = std::str::from_utf8(&updated[original.len()..]).unwrap(); + for token in [ + "/Lang(en-GB)".to_string(), + format!("/Metadata 7 {generation} R"), + format!("/OutputIntents[8 {generation} R]"), + format!("/Locked[5 {generation} R]"), + "/Name(Default)".into(), + format!("/Root 1 {generation} R"), + ] { + assert!(appended.contains(&token), "missing {token}: {appended}"); + } + assert!(!appended.contains("9999000")); + let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap(); + assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible)); + } +} + +#[test] +fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + let mut stream_pdf = original[..offset].to_vec(); + let mut entries = vec![0, 0, 0, 0, 0, 255, 255]; + for number in 1..=9 { + let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() }; + entries.push(1); + entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes()); + entries.extend_from_slice(&[0, 0]); + } + stream_pdf.extend_from_slice(format!("9 0 obj\n<>\nstream\n", entries.len()).as_bytes()); + stream_pdf.extend_from_slice(&entries); + stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] { + let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9")); + assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none()); + } +} + +#[test] +fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let first = original.len(); + let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<>\n"); + let main = first + link(0).len(); + let mut forward = original.clone(); + forward.extend_from_slice(link(main).as_bytes()); + let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes()); + forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let mut cyclic = original; + cyclic.extend_from_slice(link(first).as_bytes()); + cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none()); +} + +fn add_pdf_revision(bytes: &mut Vec) { + let text = std::str::from_utf8(bytes).unwrap(); + let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + let xref = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); +} + +fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value { + s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100})).unwrap() + ["id"] + .clone() +} + +fn pixel(s: &Session, x: u32) -> [u8; 4] { + let mut renderer = designcraft_render::Renderer::new(); + renderer.threads = 0; + renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50) +} + +#[test] +fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() { + for generation in [0, 1] { + let bytes = two_layer_pdf_options(generation, "", "/Encrypt null"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let id = place(&mut s, &bytes, 0.0); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); + assert_eq!(pixel(&s, 25), [0, 0, 255, 255]); + assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); + s.execute("file.exportPdf", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); + } +} + +#[test] +fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() { + let mut unsupported = two_layer_pdf(); + for _ in 0..32 { + add_pdf_revision(&mut unsupported); + } + assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); + assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); + let good = place(&mut s, &two_layer_pdf(), 0.0); + let bad = place(&mut s, &unsupported, 100.0); + s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().history.redo.is_empty()); + let before = s.execute("file.serialize", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + for ids in [json!([good, bad]), json!([bad, good])] { + let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err(); + assert!(err.to_string().contains("could not be changed")); + assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(s.doc().unwrap().history.redo.len(), redo); + for id in [&good, &bad] { + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true); + } + } + assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); + assert_eq!(pixel(&s, 175), [255, 0, 0, 255]); + // A persisted/relinked choice can be unrewritable. Reset must still work. + let bad_id = serde_json::from_value(bad.clone()).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()]; + assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas"); + assert!(s.execute("file.exportPdf", &json!({})).is_err()); + s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true); + s.execute("file.exportPdf", &json!({})).unwrap(); +} + +#[test] +fn explicit_all_on_validates_an_actual_rewrite_before_editing() { + // 33 xref sections: the layers stay readable, but the visibility rewrite refuses the history. + let mut unsupported = two_layer_pdf(); + for _ in 0..32 { + add_pdf_revision(&mut unsupported); + } + assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); + assert!(designcraft_render::pdf_hide_layers(&unsupported, &[]).is_none()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); + let good = place(&mut s, &two_layer_pdf(), 0.0); + let bad = place(&mut s, &unsupported, 100.0); + let before = s.execute("file.serialize", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for ids in [json!([good, bad]), json!([bad, good]), json!([bad])] { + let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": [], "useDefaults": false})).unwrap_err(); + assert!(err.to_string().contains("could not be changed"), "{err}"); + assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before, "rejected all-on must not edit any target"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + assert_eq!(pixel(&s, 175), [255, 0, 0, 255], "the unsupported source keeps showing its file defaults"); + s.execute("file.exportPdf", &json!({})).unwrap(); + // The supported source alone still accepts the explicit all-on choice. + s.execute("object.layerOptions", &json!({"ids": [good], "hidden": [], "useDefaults": false})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": good})).unwrap()[1]["overridden"], true); + // Resetting to file defaults needs no variant, so it bypasses the rewrite check. + for reset in [json!({"ids": [good, bad], "hidden": []}), json!({"ids": [good, bad], "useDefaults": true})] { + s.execute("object.layerOptions", &reset).unwrap(); + for id in [&good, &bad] { + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["overridden"], false); + } + } + // A stale explicit all-on choice that outlived its rewritable source still fails closed. + let bad_id = serde_json::from_value(bad.clone()).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_layer_override = true; + assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "unrewritable explicit choice must not draw the unmodified source"); + assert!(s.execute("file.exportPdf", &json!({})).is_err()); +} + +#[test] +fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { + for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { + let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}")); + assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}"); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}"); + } + for trailer in ["", "/Prev null"] { + let bytes = two_layer_pdf_options(0, "", trailer); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}"); + } + let mut revised = two_layer_pdf(); + let prev = + std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::().unwrap(); + add_pdf_revision(&mut revised); + let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>")); + assert!(real_prev.contains(&format!("/Prev {prev}.0>>"))); + for bytes in [revised, real_prev.into_bytes()] { + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work"); + } +} + +#[test] +fn fake_null_trailer_cannot_bypass_hybrid_rejection() { + for real_guard in ["", "/XRefStm 0"] { + let bytes = two_layer_pdf_options(0, "", real_guard); + let text = std::str::from_utf8(&bytes).unwrap(); + for fake in ["/Encrypt null", ""] { + let comment = format!("% trailer <>\n"); + let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1); + assert!(inside.contains(&format!("xref\n{comment}"))); + // Repair-only comments inside a table are always refused, guard or not. + assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2); + assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none()); + // Before xref or after the dictionary, the real trailer decides. + let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1); + let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1); + for source in [leading, trailing] { + assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2); + assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty()); + } + } + } +} + +#[test] +fn content_all_still_measures_layerless_and_stub_properties() { + use designcraft_doc::PdfCrop; + for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<>>>"] { + let content = "0 0 1 rg 0 0 50 100 re f"; + let objs = [ + format!("<>"), + "<>".into(), + "<>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for at in offsets { + bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + assert!(designcraft_render::pdf_layers(&bytes).is_empty()); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}"); + } +} + +#[test] +fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() { + use designcraft_doc::PdfCrop; + let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", ""); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0)); + for _ in 0..32 { + add_pdf_revision(&mut bytes); + } + assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none()); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0))); +} + +#[test] +fn placed_pdf_layers_hide() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = two_layer_pdf(); + let r = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "art.pdf", "x": 100, "y": 100, "width": 100})) + .unwrap(); + let id = r["id"].clone(); + let l = s.execute("object.pdfLayers", &json!({"id": id})).unwrap(); + assert_eq!(l.as_array().unwrap().len(), 2); + assert_eq!(l[0]["name"], "Blue"); + let px = |s: &Session, x: u32| { + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 150) + }; + assert!(px(&s, 125)[2] > 200 && px(&s, 175)[0] > 200, "both layers show"); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + let right = px(&s, 175); + assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}"); + assert!(px(&s, 125)[2] > 200, "blue still shows"); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + let image = designcraft_render::render_pdf_page(&bytes, 0, 792).unwrap(); + let width = usize::from(image.width()); + assert!(image.data()[150 * width + 125].a > 200); + assert_eq!(image.data()[150 * width + 175].a, 0); +} +#[test] +fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() { + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100})) + .unwrap()["id"] + .clone(); + let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap(); + let pixel = |s: &Session| { + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150) + }; + assert!(!visible(&mut s)); + assert!(pixel(&s)[1] > 200, "red hidden by default"); + s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap(); + assert!(visible(&mut s)); + assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!visible(&mut s)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(visible(&mut s)); + let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap(); + let roundtrip = designcraft_format::load(&bytes).unwrap(); + let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); + assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty()); + let exported = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap()); + let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap(); + let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap(); + assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}"); + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap()); + let imported = designcraft_idml::import_idml(&bytes).unwrap(); + let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); + assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on)); + assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err()); + assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap(); + assert!(!visible(&mut s), "legacy empty list still resets defaults"); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap(); + s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap(); + assert!(!visible(&mut s), "explicit reset restores defaults"); +} +#[test] +fn parent_page_choices_reach_idml_variants_and_raster_export() { + // Red is hidden by the file itself, so "show every layer" is a visible difference. + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let id = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&pdf), "name": "on-parent.pdf", "spread": {"kind": "parent", "index": 0}, "x": 0.0, "y": 0.0, "width": 100}), + ) + .unwrap()["id"] + .clone(); + let item = designcraft_doc::ItemId(id.as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.find(item).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)), "the placement sits on a parent page"); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": [], "useDefaults": false})).unwrap(); + // Embedded IDML: the parent-page placement gets its own variant, like a document one. + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap(); + let asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); + assert!(designcraft_render::pdf_layers(&asset.data).iter().all(|(_, on)| *on), "the embedded copy shows every layer"); + // An external-only copy would keep the original file's defaults: the choice is rejected. + let err = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string(); + assert!(err.contains("embedded images"), "{err}"); + // PDF/A-2b rasterises a parent-page placement with a choice, warnings included. + let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}"); +} + +/// An object anchored in a story is a placement too: the IDML copy gets a variant that shows +/// every layer, an external-only copy is refused, and PDF/A-2b rasterises it, rather than each +/// quietly falling back to the file's defaults. +#[test] +fn an_anchored_placement_keeps_its_layer_choice_in_saves_and_exports() { + use designcraft_doc::{AnchoredObject, Content, Graphic, Item, Shape, SpreadRef}; + use designcraft_geom::{Affine, Rect, shapes}; + + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut d = designcraft_doc::Document::new(&Default::default()); + let asset = designcraft_doc::AssetId(d.alloc()); + d.add_asset(designcraft_doc::Asset { + id: asset, + name: "anchored.pdf".into(), + mime: "application/pdf".into(), + data: std::sync::Arc::new(pdf), + ..Default::default() + }); + let mut item = + Item::new(designcraft_doc::ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + item.pdf_layer_override = true; + item.content = Content::Graphic(Graphic { + asset, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + layer_options: None, + }); + let layer = d.default_layer(); + let (_, story) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); + d.story_mut(story).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() }); + assert!(d.has_pdf_layer_overrides(), "the anchored placement carries the choice"); + let mut s = Session::new(); + s.add_document(crate::DocState::new(d, None)); + // Embedded IDML carries a variant that shows every layer. + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap(); + let copies: Vec<_> = imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).collect(); + assert!(!copies.is_empty(), "the anchored placement is exported"); + assert!( + copies.iter().all(|a| designcraft_render::pdf_layers(&a.data).iter().all(|(_, on)| *on)), + "the embedded copy must not fall back to the file's defaults" + ); + // An external-only copy would keep the original file's defaults. + assert!(s.execute("file.exportIdml", &json!({"embedImages": false})).is_err()); + // PDF/A-2b rasterises a placement with a choice, warnings included. + let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}"); +} + +/// Relinking a placed PDF to an image leaves the item's layer fields behind. They are no longer a +/// placed-PDF choice: the package report asks for a graphic whose asset is a PDF, the same thing +/// the export walks ask for, so it does not name the stale field, and exports run unaffected. +#[test] +fn a_stale_choice_on_a_non_pdf_placement_is_not_a_placed_pdf_choice() { + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "photo.png", "x": 0, "y": 0, "width": 100})) + .unwrap()["id"] + .clone(); + let item = designcraft_doc::ItemId(id.as_u64().unwrap()); + // The state a relink from a PDF leaves behind: fields set, asset no longer a PDF. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(item).unwrap().pdf_hidden_layers = vec!["Red".into()]; + let d = &s.doc().unwrap().doc; + assert!(d.item(item).unwrap().has_pdf_layer_override(), "the stale field is on the item"); + assert!(d.has_pdf_layer_overrides(), "the field is there"); + assert!(!super::super::interchange::has_applicable_pdf_layer_overrides(d), "but it is not a placed-PDF choice"); + // Nothing is dropped: the field is not a choice any export applies. + s.execute("file.exportIdml", &json!({})).unwrap(); + s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + s.execute("file.exportPdf", &json!({})).unwrap(); +} + +#[test] +fn layerless_pdf_rejects_overrides_without_breaking_idml_export() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); + let id = s.execute("file.place", &json!({"base64":pdf,"name":"plain.pdf","x":0,"y":0,"width":100})).unwrap()["id"].clone(); + let layered = s + .execute("file.place", &json!({"base64":super::super::file::base64_encode(&two_layer_pdf()),"name":"layered.pdf","x":120,"y":0,"width":100})) + .unwrap()["id"] + .clone(); + let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + assert!(s.execute("object.layerOptions", &json!({"ids":[layered,id],"hidden":[],"useDefaults":false})).is_err()); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before); + assert!(s.execute("file.exportIdml", &json!({})).is_ok()); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":true})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override()); +} + +#[test] +fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let placed = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100})) + .unwrap(); + let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap(); + for ids in [json!([other["id"], placed["id"]]), json!([placed["id"], other["id"]])] { + s.execute("selection.set", &json!({"ids":ids})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({})).unwrap().as_array().unwrap().len(), 2); + } + assert!(s.execute("object.pdfLayers", &json!({"id":other["id"]})).is_err(), "explicit non-PDF id remains an error"); + s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err()); +} diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 1053fafc0e..4f9aaf1aa0 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -464,318 +464,7 @@ pub(super) fn place_text( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn place_text_file_with_autoflow() { - let dir = std::env::temp_dir().join(format!("dc-place-text-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - let para = "The quick brown fox jumps over the lazy dog, again and again, to fill the page. ".repeat(12); - std::fs::write(&path, vec![para.as_str(); 60].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); - assert!(r["pagesAdded"].as_u64().unwrap() >= 2, "{r}"); - assert_eq!(r["overset"], false); - let d = &s.doc().unwrap().doc; - let sid = StoryId(r["story"].as_u64().unwrap()); - assert_eq!(d.story(sid).unwrap().frames.len(), d.page_count()); - assert_eq!(d.story(sid).unwrap().paras.len(), 60); - // Into the insertion point of another frame. - let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Start end"})).unwrap(); - let sid2 = fr["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid2, "anchor": 6, "focus": 6})).unwrap(); - let small = dir.join("small.rtf"); - std::fs::write(&small, br"{\rtf1 {\b middle}}").unwrap(); - s.execute("file.place", &json!({"path": small.to_string_lossy()})).unwrap(); - let st = s.doc().unwrap().doc.story(StoryId(sid2)).unwrap().clone(); - assert_eq!(st.text, "Start middle end"); - assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_sets_one_frame_per_column() { - // Regression (#625): autoflow set one frame across the margins, over the gutter. - let dir = std::env::temp_dir().join(format!("dc-place-columns-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - let para = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); - std::fs::write(&path, vec![para.as_str(); 40].join("\n")).unwrap(); - for binding in ["leftToRight", "rightToLeft"] { - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); - s.execute("layout.documentSetup", &json!({"binding": binding})).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; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert!(d.page_count() >= 3, "{binding}: {r}"); - // Two frames on every page but maybe the last, in column order, each on its column. - assert!(frames.len() > 2 * (d.page_count() - 1), "{binding}: {} frames", frames.len()); - for (i, f) in frames.iter().enumerate() { - let page = d.page_of_item(*f).unwrap(); - assert_eq!(page, i / 2, "{binding}: frame {i}"); - let mut cols = d.page(page).unwrap().column_rects(); - if binding == "rightToLeft" { - cols.reverse(); - } - assert_eq!(d.item(*f).unwrap().bounds(), cols[i % 2], "{binding}: frame {i}"); - } - } - // One column: one frame on the live area per page, as before. - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "margins": 54})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); - let d = &s.doc().unwrap().doc; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert_eq!(frames.len(), d.page_count()); - for f in frames { - assert_eq!(d.item(*f).unwrap().bounds(), d.page(d.page_of_item(*f).unwrap()).unwrap().margin_rect()); - } - 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())); - 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); 30].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 400, 300], "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - let d = &s.doc().unwrap().doc; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert_eq!(d.item(frames[0]).unwrap().bounds(), Rect::new(100.0, 100.0, 400.0, 300.0)); - let cols = d.page(1).unwrap().column_rects(); - assert_eq!(d.page_of_item(frames[1]), Some(1)); - assert_eq!(d.item(frames[1]).unwrap().bounds(), cols[0]); - assert_eq!(d.item(frames[2]).unwrap().bounds(), cols[1]); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A facing-pages document with two columns, its inside margin wider than the outside one, so - /// left and right pages have their columns in different places. A graphic frame sits on the - /// page `marker` (0-based). Returns the session and the marker's id. - fn facing_document(pages: usize, marker: usize) -> (Session, u64) { - let mut s = Session::new(); - let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36}); - s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins})) - .unwrap(); - let d = &s.doc().unwrap().doc; - let (si, _) = d.page_loc(marker).unwrap(); - let m = d.page(marker).unwrap().margin_rect(); - let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0]; - let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - // Selected, an empty frame would take the placed text. - s.execute("edit.deselectAll", &json!({})).unwrap(); - (s, id) - } - - /// Every frame of the story on the column of its page, the story running through the pages in - /// order, two frames a page. - fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) { - for (i, f) in frames.iter().enumerate() { - let page = d.page_of_item(*f).unwrap(); - assert_eq!(page, i / 2, "frame {i}"); - assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}"); - } - } - - #[test] - fn autoflow_fills_the_pages_that_follow_before_adding_any() { - // Gap 26: autoflow inserted pages after the first frame's page, in front of the empty - // pages the document already had. InDesign threads frames on those first. - let dir = std::env::temp_dir().join(format!("dc-place-following-{}", 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); 24].join("\n")).unwrap(); - let (mut s, marker) = facing_document(6, 2); - let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - assert_eq!(r["pagesAdded"], 0, "{r}"); - let d = &s.doc().unwrap().doc; - assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); - let sid = StoryId(r["story"].as_u64().unwrap()); - let frames = d.story(sid).unwrap().frames.clone(); - // Past page 3, which holds the marker: frames go on a page whatever else it holds. - assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len()); - assert_frames_in_columns(d, &frames); - // The text ends before the document does: no empty frame after it. - let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()))); - assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5); - assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_adds_pages_at_the_end_of_the_document() { - let dir = std::env::temp_dir().join(format!("dc-place-end-{}", 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); 60].join("\n")).unwrap(); - let (mut s, marker) = facing_document(3, 2); - let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); - 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() > 4, "{r}"); - assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}"); - // The document's own pages come first, the added ones after the last of them. - assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::>(), ids); - assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); - let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone(); - assert_frames_in_columns(d, &frames); - assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1)); - // One undo step takes the frames, the pages and the story away. - s.execute("edit.undo", &json!({})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); - assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none()); - assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 1); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_fills_every_column_of_a_following_page() { - // A first frame across the whole live area holds twice what a column does, so autoflow - // asks for half the frames it needs. Asked page by page, it left the later columns of a - // page out of the thread and went on to the next page. - let dir = std::env::temp_dir().join(format!("dc-place-under-{}", 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); 30].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); - let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - assert_eq!(r["pagesAdded"], 0, "{r}"); - let d = &s.doc().unwrap().doc; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert_eq!(d.item(frames[0]).unwrap().bounds(), m); - assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len()); - for (k, f) in frames[1..].iter().enumerate() { - let page = d.page_of_item(*f).unwrap(); - assert_eq!(page, 1 + k / 2, "frame {}", k + 1); - assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() { - // A small first frame makes autoflow ask for more frames than the columns need; the ones - // it set on the following pages past the end of the text go again, the pages stay. - let dir = std::env::temp_dir().join(format!("dc-place-small-{}", 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); 12].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - assert_eq!(r["pagesAdded"], 0, "{r}"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_count(), 8); - let sid = StoryId(r["story"].as_u64().unwrap()); - let frames = &d.story(sid).unwrap().frames; - assert!(frames.len() >= 2, "{r}"); - let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len()); - assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), frames.len()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_adds_only_the_pages_breaks_ask_for() { - // Regression (#262): a frame a break ends early made autoflow take it for a frame that - // holds a few characters, and add a page per few characters left (33 for this text). - let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - for (name, rtf, pages) in [ - ("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2), - ("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4), - ] { - let path = dir.join(name); - std::fs::write(&path, rtf).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{name}: {r}"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_count(), pages, "{name}: {r}"); - assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { - // From page 1, an odd page break needs pages 2 and 3 (2 stays empty), an even one page 2. - // A frame such a break ends early holds few characters; taken for a full frame, it made - // autoflow add a page per few characters left. - use designcraft_doc::{EVEN_PAGE_BREAK, ODD_PAGE_BREAK, build::NewDocument}; - for (brk, added) in [(ODD_PAGE_BREAK, 2), (EVEN_PAGE_BREAK, 1)] { - let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); - let sid = d.settings.primary_story.unwrap(); - d.story_mut(sid).unwrap().insert( - 0, - &format!( - "a{brk} -{}", - "word ".repeat(200) - ), - ); - assert_eq!(autoflow(&mut d, sid, 50, "file.place", false).unwrap(), added, "{brk:?}"); - assert_eq!(d.page_count(), added + 1, "{brk:?}"); - assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); - } - } -} +mod tests; #[cfg(test)] mod place_pdf_tests { diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs new file mode 100644 index 0000000000..006762ab2d --- /dev/null +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +#[test] +fn place_text_file_with_autoflow() { + let dir = std::env::temp_dir().join(format!("dc-place-text-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + let para = "The quick brown fox jumps over the lazy dog, again and again, to fill the page. ".repeat(12); + std::fs::write(&path, vec![para.as_str(); 60].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); + assert!(r["pagesAdded"].as_u64().unwrap() >= 2, "{r}"); + assert_eq!(r["overset"], false); + let d = &s.doc().unwrap().doc; + let sid = StoryId(r["story"].as_u64().unwrap()); + assert_eq!(d.story(sid).unwrap().frames.len(), d.page_count()); + assert_eq!(d.story(sid).unwrap().paras.len(), 60); + // Into the insertion point of another frame. + let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Start end"})).unwrap(); + let sid2 = fr["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid2, "anchor": 6, "focus": 6})).unwrap(); + let small = dir.join("small.rtf"); + std::fs::write(&small, br"{\rtf1 {\b middle}}").unwrap(); + s.execute("file.place", &json!({"path": small.to_string_lossy()})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid2)).unwrap().clone(); + assert_eq!(st.text, "Start middle end"); + assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_sets_one_frame_per_column() { + // Regression (#625): autoflow set one frame across the margins, over the gutter. + let dir = std::env::temp_dir().join(format!("dc-place-columns-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + let para = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); + std::fs::write(&path, vec![para.as_str(); 40].join("\n")).unwrap(); + for binding in ["leftToRight", "rightToLeft"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).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; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert!(d.page_count() >= 3, "{binding}: {r}"); + // Two frames on every page but maybe the last, in column order, each on its column. + assert!(frames.len() > 2 * (d.page_count() - 1), "{binding}: {} frames", frames.len()); + for (i, f) in frames.iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, i / 2, "{binding}: frame {i}"); + let mut cols = d.page(page).unwrap().column_rects(); + if binding == "rightToLeft" { + cols.reverse(); + } + assert_eq!(d.item(*f).unwrap().bounds(), cols[i % 2], "{binding}: frame {i}"); + } + } + // One column: one frame on the live area per page, as before. + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "margins": 54})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert_eq!(frames.len(), d.page_count()); + for f in frames { + assert_eq!(d.item(*f).unwrap().bounds(), d.page(d.page_of_item(*f).unwrap()).unwrap().margin_rect()); + } + 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())); + 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); 30].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 400, 300], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert_eq!(d.item(frames[0]).unwrap().bounds(), Rect::new(100.0, 100.0, 400.0, 300.0)); + let cols = d.page(1).unwrap().column_rects(); + assert_eq!(d.page_of_item(frames[1]), Some(1)); + assert_eq!(d.item(frames[1]).unwrap().bounds(), cols[0]); + assert_eq!(d.item(frames[2]).unwrap().bounds(), cols[1]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A facing-pages document with two columns, its inside margin wider than the outside one, so +/// left and right pages have their columns in different places. A graphic frame sits on the +/// page `marker` (0-based). Returns the session and the marker's id. +fn facing_document(pages: usize, marker: usize) -> (Session, u64) { + let mut s = Session::new(); + let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36}); + s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins})).unwrap(); + let d = &s.doc().unwrap().doc; + let (si, _) = d.page_loc(marker).unwrap(); + let m = d.page(marker).unwrap().margin_rect(); + let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0]; + let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + // Selected, an empty frame would take the placed text. + s.execute("edit.deselectAll", &json!({})).unwrap(); + (s, id) +} + +/// Every frame of the story on the column of its page, the story running through the pages in +/// order, two frames a page. +fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) { + for (i, f) in frames.iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, i / 2, "frame {i}"); + assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}"); + } +} + +#[test] +fn autoflow_fills_the_pages_that_follow_before_adding_any() { + // Gap 26: autoflow inserted pages after the first frame's page, in front of the empty + // pages the document already had. InDesign threads frames on those first. + let dir = std::env::temp_dir().join(format!("dc-place-following-{}", 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); 24].join("\n")).unwrap(); + let (mut s, marker) = facing_document(6, 2); + let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); + let sid = StoryId(r["story"].as_u64().unwrap()); + let frames = d.story(sid).unwrap().frames.clone(); + // Past page 3, which holds the marker: frames go on a page whatever else it holds. + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len()); + assert_frames_in_columns(d, &frames); + // The text ends before the document does: no empty frame after it. + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()))); + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_adds_pages_at_the_end_of_the_document() { + let dir = std::env::temp_dir().join(format!("dc-place-end-{}", 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); 60].join("\n")).unwrap(); + let (mut s, marker) = facing_document(3, 2); + let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); + 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() > 4, "{r}"); + assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}"); + // The document's own pages come first, the added ones after the last of them. + assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::>(), ids); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); + let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone(); + assert_frames_in_columns(d, &frames); + assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1)); + // One undo step takes the frames, the pages and the story away. + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); + assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none()); + assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_fills_every_column_of_a_following_page() { + // A first frame across the whole live area holds twice what a column does, so autoflow + // asks for half the frames it needs. Asked page by page, it left the later columns of a + // page out of the thread and went on to the next page. + let dir = std::env::temp_dir().join(format!("dc-place-under-{}", 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); 30].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert_eq!(d.item(frames[0]).unwrap().bounds(), m); + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len()); + for (k, f) in frames[1..].iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, 1 + k / 2, "frame {}", k + 1); + assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() { + // A small first frame makes autoflow ask for more frames than the columns need; the ones + // it set on the following pages past the end of the text go again, the pages stay. + let dir = std::env::temp_dir().join(format!("dc-place-small-{}", 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); 12].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), 8); + let sid = StoryId(r["story"].as_u64().unwrap()); + let frames = &d.story(sid).unwrap().frames; + assert!(frames.len() >= 2, "{r}"); + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len()); + assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), frames.len()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_adds_only_the_pages_breaks_ask_for() { + // Regression (#262): a frame a break ends early made autoflow take it for a frame that + // holds a few characters, and add a page per few characters left (33 for this text). + let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, rtf, pages) in [ + ("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2), + ("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4), + ] { + let path = dir.join(name); + std::fs::write(&path, rtf).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{name}: {r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), pages, "{name}: {r}"); + assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { + // From page 1, an odd page break needs pages 2 and 3 (2 stays empty), an even one page 2. + // A frame such a break ends early holds few characters; taken for a full frame, it made + // autoflow add a page per few characters left. + use designcraft_doc::{EVEN_PAGE_BREAK, ODD_PAGE_BREAK, build::NewDocument}; + for (brk, added) in [(ODD_PAGE_BREAK, 2), (EVEN_PAGE_BREAK, 1)] { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + d.story_mut(sid).unwrap().insert( + 0, + &format!( + "a{brk} +{}", + "word ".repeat(200) + ), + ); + assert_eq!(autoflow(&mut d, sid, 50, "file.place", false).unwrap(), added, "{brk:?}"); + assert_eq!(d.page_count(), added + 1, "{brk:?}"); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); + } +} diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 724cc2069c..4cc8a58945 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,203 +1,5 @@ //! Paragraph / character styles and swatches. -#[cfg(test)] -mod redefine_tests { - use super::*; - - fn sample() -> (Session, Value, Value) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"First header"})).unwrap(); - let b = s.execute("frame.create", &json!({"rect":[36,240,300,400],"content":"text","text":"Second header"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name":"Header","chars":{"size":18,"fill":"[Paper]"}})).unwrap(); - for story in [&a["story"], &b["story"]] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name":"Header"})).unwrap(); - } - s.execute("text.select", &json!({"story":a["story"],"anchor":0,"focus":5})).unwrap(); - (s, a["story"].clone(), b["story"].clone()) - } - - #[test] - fn redefine_paragraph_updates_other_stories_and_clears_the_source_overrides() { - let (mut s, a, b) = sample(); - s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); - s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); - s.execute("type.char", &json!({"size":20,"fill":"[Black]"})).unwrap(); - s.execute("type.para", &json!({"spaceAfter":9})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("style.paragraph.redefine", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - assert!(s.doc().unwrap().doc.styles.para("Header").unwrap().chars.underline.is_none()); - for story in [&a, &b] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); - assert_eq!(attrs["chars"]["size"], 20.0); - assert_eq!(attrs["chars"]["fill"], "[Black]"); - assert_eq!(attrs["para"]["spaceAfter"], 9.0); - assert_eq!(attrs["chars"]["underline"], story == &a); - } - s.execute("style.paragraph.edit", &json!({"name":"Header","chars":{"size":22}})).unwrap(); - s.execute("text.select", &json!({"story":a,"anchor":0,"focus":5})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 22.0); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - } - - #[test] - fn redefine_character_does_not_capture_paragraph_font_size() { - let (mut s, a, b) = sample(); - s.execute("style.character.create", &json!({"name":"Underline","chars":{"underline":true}})).unwrap(); - for story in [&a, &b] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - s.execute("style.character.apply", &json!({"name":"Underline"})).unwrap(); - } - s.execute("type.char", &json!({"fill":"[Black]"})).unwrap(); - s.execute("style.character.redefine", &json!({"name":"Underline"})).unwrap(); - let style = s.doc().unwrap().doc.styles.char_style("Underline").unwrap(); - assert!(style.chars.size.is_none()); - assert_eq!(style.chars.underline, Some(true)); - for story in [&a, &b] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); - } - } - - #[test] - fn redefine_rejects_the_wrong_style_and_the_none_entry_atomically() { - let (mut s, _, _) = sample(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - assert!(s.execute("style.paragraph.redefine", &json!({"name":"Other"})).is_err()); - assert!(s.execute("style.character.redefine", &json!({})).is_err()); - assert_eq!(s.doc().unwrap().doc, before); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - } - - #[test] - fn redefine_character_at_a_caret_cleans_the_source_run_but_keeps_other_overrides() { - let (mut s, story, _) = sample(); - s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); - for (start, end, size) in [(0, 5, 24), (6, 12, 32)] { - s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); - s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); - s.execute("type.char", &json!({"size":size})).unwrap(); - } - let before = s.doc().unwrap().doc.clone(); - s.execute("text.select", &json!({"story":story,"anchor":2,"focus":2})).unwrap(); - s.execute("style.character.redefine", &json!({"name":"Emphasis"})).unwrap(); - s.execute("style.character.edit", &json!({"name":"Emphasis","chars":{"size":30}})).unwrap(); - for (start, end, size) in [(0, 5, 30.0), (6, 12, 32.0)] { - s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], size); - } - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - } -} - -#[cfg(test)] -mod application_inheritance_tests { - use super::*; - - fn setup(text: &str) -> (Session, Value) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [36,36,300,300], "content": "text", "text": text})).unwrap(); - (s, f["story"].clone()) - } - fn select(s: &mut Session, story: &Value, start: usize, end: usize) { - s.execute("text.select", &json!({"story": story, "anchor": start, "focus": end})).unwrap(); - } - fn attrs(s: &mut Session) -> Value { - s.execute("type.selectionAttrs", &json!({})).unwrap() - } - - #[test] - fn paragraph_capture_source_follows_later_style_edits_and_undo() { - let (mut s, story) = setup("First\nSecond"); - select(&mut s, &story, 0, 5); - s.execute("type.char", &json!({"size": 18, "fill": "[Paper]"})).unwrap(); - s.execute("type.para", &json!({"spaceAfter": 6})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Header", "fromSelection": true})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - s.execute("edit.redo", &json!({})).unwrap(); - select(&mut s, &story, 6, 6); - s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); - s.execute("style.paragraph.edit", &json!({"name": "Header", "chars": {"size": 20, "fill": "[Black]"}, "para": {"spaceAfter": 12}})).unwrap(); - for pos in [0, 6] { - select(&mut s, &story, pos, pos + 1); - let a = attrs(&mut s); - assert_eq!(a["chars"]["size"], 20.0); - assert_eq!(a["chars"]["fill"], "[Black]"); - assert_eq!(a["para"]["spaceAfter"], 12.0); - } - } - - #[test] - fn applying_styles_keeps_different_overrides_and_named_character_styles() { - let (mut s, story) = setup("First\nSecond"); - select(&mut s, &story, 0, 5); - s.execute("type.char", &json!({"size": 14})).unwrap(); - s.execute("type.para", &json!({"spaceAfter": 9})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 10}, "para": {"spaceAfter": 6}})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Body"})).unwrap(); - let a = attrs(&mut s); - assert_eq!(a["chars"]["size"], 14.0); - assert_eq!(a["para"]["spaceAfter"], 9.0); - assert_eq!(a["characterStyle"], "Emphasis"); - assert_eq!(a["chars"]["underline"], true); - } - - #[test] - fn character_capture_source_remains_sparse_and_follows_style_edits() { - let (mut s, story) = setup("First\nSecond"); - select(&mut s, &story, 0, 5); - s.execute("type.char", &json!({"underline": true})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "fromSelection": true})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"underline": false}})).unwrap(); - assert_eq!(attrs(&mut s)["chars"]["underline"], false); - assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").unwrap().chars.size.is_none()); - s.execute("style.paragraph.edit", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "chars": {"size": 20}})).unwrap(); - assert_eq!(attrs(&mut s)["chars"]["size"], 20.0); - } - - #[test] - fn paragraph_selection_excludes_the_next_paragraph_at_its_start() { - let (mut s, story) = setup("é\nNext"); - select(&mut s, &story, 0, 3); - s.execute("style.paragraph.create", &json!({"name": "Header"})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Header", "clearOverrides": true})).unwrap(); - select(&mut s, &story, 3, 4); - assert_eq!(attrs(&mut s)["paragraphStyle"], designcraft_doc::BASIC_PARAGRAPH); - select(&mut s, &story, 0, 2); - assert_eq!(attrs(&mut s)["paragraphStyle"], "Header"); - } - - #[test] - fn clearing_a_partial_selection_preserves_character_overrides_outside_it() { - let (mut s, story) = setup("First Second"); - select(&mut s, &story, 0, 12); - s.execute("type.char", &json!({"size": 18})).unwrap(); - select(&mut s, &story, 0, 5); - s.execute("style.paragraph.apply", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "clearOverrides": true})).unwrap(); - assert_ne!(attrs(&mut s)["chars"]["size"], 18.0); - select(&mut s, &story, 6, 12); - assert_eq!(attrs(&mut s)["chars"]["size"], 18.0); - } -} - use designcraft_color::{Swatch, SwatchValue}; use designcraft_doc::{CharAttrs, CharacterStyle, Effects, Item, ObjectStyle, ParaAttrs, ParagraphStyle, Styles}; use serde_json::{Value, json}; @@ -205,6 +7,20 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, ok, str_param}; use crate::{Result, Session}; +mod color; +mod object; +mod paragraph; +mod swatches; + +use color::{apply_color, apply_gradient, has_text_or_selection, sample_color}; +use object::{apply_object, clear_object_overrides, create_object, edit_object}; +use paragraph::{ + apply_char, apply_para, create_char, create_para, delete_char, delete_para, edit_char, edit_para, export_tag_key, group_styles, redefine, +}; +pub(crate) use paragraph::{check_grep_styles, grep_style_warnings}; +pub use swatches::parse_color; +use swatches::{add_to_swatches, create_swatch, load_swatches, new_swatch, save_swatches, swatch_options}; + pub fn specs() -> Vec { vec![ cmd!(query "style.compositeFont.list", "List Composite Fonts", [], None, "{} → composite font definitions", has_doc, |s, _| { @@ -665,3083 +481,8 @@ pub fn specs() -> Vec { ] } -/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute -/// is that command's parameter error, naming the group. -fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { - let mut a = T::default(); - match p.get(group) { - None | Some(Value::Null) => {} - Some(Value::Object(o)) => { - for (k, v) in o { - set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?; - } - } - Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))), - } - Ok(a) -} - -fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { - let command = if paragraph { "style.paragraph.redefine" } else { "style.character.redefine" }; - let targets = super::text::format_targets_pub(s); - let target = targets.first().ok_or_else(|| bad(command, "select text using the style to redefine"))?; - let state = s.doc()?; - let story = state.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(command, "no paragraph at the selection"))?; - let cf = super::text::effective_char_format(s, target)?; - let selected = if paragraph { &pf.style } else { &cf.style }; - let name = str_param(p, "name").unwrap_or(selected).to_string(); - if name != *selected || name == if paragraph { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { - return Err(bad(command, "select text using the named style; the no-style entry cannot be redefined")); - } - let (base_para, base_chars) = state.doc.styles.resolve_para_style(&pf.style); - let (resolved_para, paragraph_chars) = state.doc.styles.resolve_para(pf); - let para_changes = base_para.delta(&resolved_para); - let mut current = if paragraph { - paragraph_chars.clone() - } else { - state.doc.styles.resolve_char(¶graph_chars, &designcraft_doc::CharFormat { style: name.clone(), over: CharAttrs::default() }) - }; - let inherited = if paragraph { base_chars } else { current.clone() }; - // A named character style is an intentional layer, not a paragraph override to absorb. - current.apply(&cf.over); - let char_changes = inherited.delta(¤t); - let result = s.edit(|d, _| { - if paragraph { - let style = d.styles_mut().para_mut(&name).ok_or_else(|| bad(command, "paragraph style no longer exists"))?; - style.para.merge(¶_changes); - style.chars.merge(&char_changes); - } else { - let style = - d.styles_mut().character.iter_mut().find(|st| st.name == name).ok_or_else(|| bad(command, "character style no longer exists"))?; - style.chars.merge(&char_changes); - } - let styles = d.styles.clone(); - for target in &targets { - let Some(story) = d.text_story_mut(target.story, target.cell) else { continue }; - // At a character-style caret, the sampled format belongs to the containing run - // (the preceding run at a boundary). Normalize that source run too, otherwise its - // old local values would mask the next edit of the redefined style. - let mut selected_range = target.range.clone(); - if !paragraph && selected_range.is_empty() { - let caret = selected_range.start; - let mut start = 0usize; - for run in &story.chars { - let end = start.saturating_add(run.len).min(story.len()); - if caret < end || (caret == end && caret > start) { - selected_range = start..end; - break; - } - start = end; - } - } - let end = if selected_range.is_empty() { selected_range.start } else { selected_range.end.saturating_sub(1) }; - let (first, last) = (story.para_at(selected_range.start), story.para_at(end)); - for (index, range) in story.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { - let Some(pf) = story.paras.get_mut(index) else { continue }; - if paragraph && pf.style != name { - continue; - } - if paragraph { - let (pp, cp) = styles.resolve_para(pf); - let (base_p, base_c) = styles.resolve_para_style(&name); - pf.para = base_p.delta(&pp); - pf.chars = base_c.delta(&cp); - } - let (_, base) = styles.resolve_para(pf); - let end = range.end.saturating_add(1).min(story.len()); - let range = if paragraph { range.start..end } else { range.start.max(selected_range.start)..end.min(selected_range.end) }; - story.format_chars(range, |format| { - if !paragraph && format.style != name { - return; - } - let mut inherited = base.clone(); - inherited.apply(&styles.char_style_attrs(&format.style)); - let resolved = styles.resolve_char(&base, format); - format.over = inherited.delta(&resolved); - }); - } - } - Ok(json!({"name":name})) - }); - if result.is_ok() { - s.typing_format = None; - } - result -} - -/// Apply paragraph style `name` over `r` in one story; `clear` drops overrides instead of keeping -/// only those that differ from the style (`resolved` is the style's paragraph and character attributes). -fn apply_para_in_story( - st: &mut designcraft_doc::Story, - r: &std::ops::Range, - name: &str, - clear: bool, - resolved: (&designcraft_doc::ParaProps, &designcraft_doc::CharProps), - styles: &designcraft_doc::Styles, -) { - let (style_para, style_chars) = resolved; - // Text selections are half-open: a selection ending at the next paragraph's - // start must not apply a style to that paragraph too. - let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; - let (first, last) = (st.para_at(r.start), st.para_at(end)); - st.format_paras(r.start..end, |f| { - f.style = name.to_string(); - if clear { - f.para = ParaAttrs::default(); - f.chars = CharAttrs::default(); - } else { - f.para.retain_differences(style_para); - f.chars.retain_differences(style_chars); - } - }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, base) = styles.resolve_para(pf); - let end = range.end.saturating_add(1).min(st.len()); - let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; - st.format_chars(range, |f| { - if clear { - f.over = CharAttrs::default(); - } else { - let mut inherited = base.clone(); - inherited.apply(&styles.char_style_attrs(&f.style)); - f.over.retain_differences(&inherited); - } - }); - } -} - -fn apply_para(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); - let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); - if s.doc()?.doc.styles.para(&name).is_none() { - return Err(bad("style.paragraph.apply", format!("no paragraph style `{name}`"))); - } - let targets = super::text::format_targets_pub(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - let (style_para, style_chars) = styles.resolve_para_style(&name); - for t in &targets { - if let Some(st) = d.text_story_mut(t.story, t.cell) { - apply_para_in_story(st, &t.range, &name, clear, (&style_para, &style_chars), &styles); - } - } - ok() - }) -} - -fn apply_char(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); - if s.doc()?.doc.styles.char_style(&name).is_none() { - return Err(bad("style.character.apply", format!("no character style `{name}`"))); - } - let targets = super::text::format_targets_pub(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - if let Some(st) = d.text_story_mut(t.story, t.cell) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { - let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); - if range.is_empty() { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, mut inherited) = styles.resolve_para(pf); - inherited.apply(&styles.char_style_attrs(&name)); - st.format_chars(range, |f| { - f.style = name.clone(); - f.over.retain_differences(&inherited); - }); - } - } - } - ok() - }) -} - -/// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit -/// "continue" so it also overrides a Start At the style would inherit. -fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { - let explicit = - p.and_then(Value::as_object).is_some_and(|o| o.iter().any(|(k, v)| v.is_null() && k.replace('_', "").eq_ignore_ascii_case("startAt"))); - if explicit { - para.start_at = Some(None); - } -} - -fn create_para(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); - let mut para: ParaAttrs = - attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; - continue_numbering(p.get("para"), &mut para); - explicit_default_kinsoku(&mut para, p.get("para")); - check_grep_styles("style.paragraph.create", ¶)?; - let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; - if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) - && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) - && !cur.is_null() - { - if let Ok(pp) = serde_json::from_value::(cur["para"].clone()) { - let mut full = ParaProps_to_attrs(&pp); - full.merge(¶); - para = full; - } - if let Ok(cp) = serde_json::from_value::(cur["chars"].clone()) { - let mut full = CharProps_to_attrs(&cp); - full.merge(&chars); - chars = full; - } - } - let based_on = str_param(p, "basedOn").map(str::to_string); - let next = str_param(p, "nextStyle").map(str::to_string); - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); - if let Some(b) = &based_on - && d.styles.para(b).is_none() - { - return Err(bad("style.paragraph.create", format!("no style `{b}`"))); - } - d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); - Ok(json!({"name": name})) - }) -} - -/// `kinsoku: null` sets the composer's default kinsoku rules, also over a Based On style's set. -/// For the other attributes `null` leaves the style's value as it is. -fn explicit_default_kinsoku(para: &mut ParaAttrs, given: Option<&Value>) { - if given.and_then(|o| o.get("kinsoku")).is_some_and(Value::is_null) { - para.kinsoku = Some(None); - } -} - -/// The compile error of the first GREP style in `a` whose pattern can't compile. -pub(crate) fn grep_style_error(a: &ParaAttrs) -> Option { - a.grep_styles - .iter() - .flatten() - .filter(|g| !g.pattern.is_empty()) - .find_map(|g| designcraft_compose::grep::Grep::new(&g.pattern, Default::default()).err().map(|e| format!("{e} (pattern {:?})", g.pattern))) -} - -/// Rejects GREP styles a command would set with a pattern that can't compile. -pub(crate) fn check_grep_styles(cmd: &str, a: &ParaAttrs) -> Result<()> { - grep_style_error(a).map_or(Ok(()), |e| Err(bad(cmd, e))) -} - -/// One warning for each paragraph style or paragraph whose GREP style pattern can't compile. -/// Such a GREP style stays in the document and composition skips it. -pub(crate) fn grep_style_warnings(d: &designcraft_doc::Document) -> Vec { - let mut out: Vec = d - .styles - .paragraph - .iter() - .filter_map(|st| grep_style_error(&st.para).map(|e| format!("paragraph style {:?}: GREP style not applied: {e}", st.name))) - .collect(); - for st in d.stories.values() { - for (i, pf) in st.paras.iter().enumerate() { - if let Some(e) = grep_style_error(&pf.para) { - out.push(format!("story {} paragraph {}: GREP style not applied: {e}", st.id.0, i + 1)); - } - } - } - out -} - -#[allow(non_snake_case)] -fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs { - designcraft_doc::ParaProps::common([p]) -} -#[allow(non_snake_case)] -fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { - designcraft_doc::CharProps::common([p]) -} - -fn edit_para(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); - let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; - let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); - let based = p.get("basedOn").cloned(); - s.edit(|d, _| { - if let Some(Value::String(b)) = &based - && d.styles.para_based_on_cycles(&name, b) - { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); - } - if let Some(n) = &rename { - if n.is_empty() || n.starts_with('[') { - return Err(bad("style.paragraph.edit", format!("can't rename to `{n}`"))); - } - if d.styles.para(n).is_some() { - return Err(bad("style.paragraph.edit", format!("a paragraph style named `{n}` already exists"))); - } - } - // A rule object changes only the rule fields it names, over the style's resolved rule. - let (current, _) = d.styles.resolve_para_style(&name); - let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; - continue_numbering(p.get("para"), &mut para); - explicit_default_kinsoku(&mut para, p.get("para")); - check_grep_styles("style.paragraph.edit", ¶)?; - let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; - st.para.merge(¶); - st.chars.merge(&chars); - match based { - Some(Value::String(b)) => st.based_on = Some(b), - Some(Value::Null) => st.based_on = None, - _ => {} - } - if let Some(n) = rename.clone() { - // Every use: other styles, stories, table cells, footnotes, object styles. - rename_style(d, true, &name, &n); - } - ok() - }) -} - -/// Rename a paragraph or character style everywhere it's used; its Export Tagging follows. -fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { - let st = d.styles_mut(); - if para { - for s in &mut st.paragraph { - if s.name == from { - s.name = to.to_string(); - } - } - } else { - for s in &mut st.character { - if s.name == from { - s.name = to.to_string(); - } - } - } - // An entry already under `to` belongs to no style (names are unique), so it never survives. - st.export_tags.remove(&export_tag_key(para, to)); - if let Some(tag) = st.export_tags.remove(&export_tag_key(para, from)) { - st.export_tags.insert(export_tag_key(para, to), tag); - } - redirect_style_refs(d, para, from, to); -} - -/// The `Styles::export_tags` key of a paragraph (`para`) or character style. -fn export_tag_key(para: bool, name: &str) -> String { - format!("{}:{name}", if para { "p" } else { "c" }) -} - -/// Point every reference to style `from` at `to`, except the style's own registry entry (its -/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which -/// instead removes the entry, after its former uses point at the replacement). -fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { - let rename = |r: &mut String| { - if r == from { - *r = to.to_string(); - } - }; - let st = d.styles_mut(); - // A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent. - let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned(); - if para { - let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); - for s in &mut st.paragraph { - if s.based_on.as_deref() == Some(from) { - s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; - } - if s.next_style.as_deref() == Some(from) { - s.next_style = Some(to.to_string()); - } - } - let objects = st.object.iter_mut().map(|o| &mut o.paragraph_style); - for r in objects.chain(st.cell.iter_mut().map(|c| &mut c.paragraph_style)) { - if r.as_deref() == Some(from) { - *r = Some(to.to_string()); - } - } - rename(&mut st.default_paragraph); - let (fo, eo) = (&mut d.footnote_options, &mut d.endnote_options); - [&mut fo.para_style, &mut eo.para_style, &mut eo.heading_style].into_iter().for_each(rename); - d.toc.iter_mut().flat_map(|t| t.entries.iter_mut().map(|e| &mut e.style)).for_each(rename); - for t in d.toc.iter_mut() { - let formats = t.entries.iter_mut().map(|e| &mut e.entry_style).chain([&mut t.title_style]); - for r in formats.filter(|r| r.as_deref() == Some(from)) { - *r = Some(to.to_string()); - } - } - if let designcraft_doc::EpubSplit::Style(n) = &mut d.settings.epub_split { - rename(n); - } - } else { - let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); - for s in &mut st.character { - if s.based_on.as_deref() == Some(from) { - s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; - } - } - for s in &mut st.paragraph { - rename_char_refs(&mut s.para, from, to); - } - rename(&mut st.default_character); - rename(&mut d.footnote_options.ref_char_style); - } - // The table of contents' character styles ([None] is stored as no style). - if !para && let Some(toc) = d.toc.as_mut() { - for e in &mut toc.entries { - for n in [&mut e.page_number_style, &mut e.separator_style] { - if n.as_deref() == Some(from) { - *n = Some(to.to_string()).filter(|t| t != designcraft_doc::NO_CHAR_STYLE); - } - } - } - } - for sid in d.stories.keys().copied().collect::>() { - let Some(story) = d.story_mut(sid) else { continue }; - story.for_each_text_mut(&mut |st| { - if para { - for f in st.paras.iter_mut().filter(|f| f.style == from) { - f.style = to.to_string(); - } - } else { - for r in st.chars.iter_mut().filter(|r| r.format.style == from) { - r.format.style = to.to_string(); - } - for f in &mut st.paras { - rename_char_refs(&mut f.para, from, to); - } - } - st.rev = st.rev.wrapping_add(1); - }); - } -} - -/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph -/// attributes at a renamed (or deleted, pointed at its replacement) character style. -fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { - let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); - let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); - let names = names.chain(p.drop_cap_style.iter_mut()); - for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) { - if n == from { - *n = to.to_string(); - } - } -} - -fn group_styles(s: &mut Session, p: &Value) -> Result { - let para = match str_param(p, "kind").unwrap_or("paragraph") { - "paragraph" => true, - "character" => false, - k => return Err(bad("style.group", format!("unknown kind `{k}`"))), - }; - let names: Vec = - p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - let group = str_param(p, "group").unwrap_or("").trim_matches('/').to_string(); - s.edit(|d, _| { - let mut renamed = serde_json::Map::new(); - for n in &names { - if n.starts_with('[') { - return Err(bad("style.group", format!("`{n}` is built in"))); - } - let exists = if para { d.styles.para(n).is_some() } else { d.styles.character.iter().any(|c| c.name == *n) }; - if !exists { - return Err(bad("style.group", format!("no style `{n}`"))); - } - let base = n.rsplit('/').next().unwrap_or(n); - let mut to = if group.is_empty() { base.to_string() } else { format!("{group}/{base}") }; - let taken = |d: &designcraft_doc::Document, x: &str| { - if para { d.styles.para(x).is_some() } else { d.styles.character.iter().any(|c| c.name == x) } - }; - if to != *n { - to = Styles::unique_name(|x| taken(d, x), &to); - rename_style(d, para, n, &to); - renamed.insert(n.clone(), json!(to)); - } - } - Ok(json!({"renamed": renamed})) - }) -} - -fn delete_para(s: &mut Session, p: &Value) -> Result { - delete_style(s, p, true) -} - -fn delete_char(s: &mut Session, p: &Value) -> Result { - delete_style(s, p, false) -} - -/// Delete a paragraph (`para`) or character style; everything that used it uses `replaceWith` -/// (default [Basic Paragraph] or [None]) instead, in one undo step. -fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { - let (id, default) = - if para { ("style.paragraph.delete", designcraft_doc::BASIC_PARAGRAPH) } else { ("style.character.delete", designcraft_doc::NO_CHAR_STYLE) }; - let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad(id, "missing name"))?.to_string(); - let repl = str_param(p, "replaceWith").unwrap_or(default).to_string(); - if name.starts_with('[') { - return Err(bad(id, "built-in styles can't be deleted")); - } - if repl == name { - return Err(bad(id, "`replaceWith` can't be the deleted style")); - } - s.edit(|d, _| { - let st = &d.styles; - let exists = |n: &str| if para { st.para(n).is_some() } else { n == designcraft_doc::NO_CHAR_STYLE || st.char_style(n).is_some() }; - if !exists(&name) { - return Err(bad(id, format!("no style `{name}`"))); - } - if !exists(&repl) { - return Err(bad(id, format!("no style `{repl}` to replace it with"))); - } - // The deleted style's children are based on the replacement, or on the deleted style's - // own parent when the replacement descends from it (so no based-on chain loops). - let (own_parent, descends) = if para { - (st.para(&name).and_then(|x| x.based_on.clone()), st.para_based_on_cycles(&name, &repl)) - } else { - (st.char_style(&name).and_then(|x| x.based_on.clone()), st.char_based_on_cycles(&name, &repl)) - }; - let parent = if descends { - own_parent.filter(|b| *b != name) - } else if repl == designcraft_doc::NO_CHAR_STYLE || repl == designcraft_doc::NO_PARA_STYLE { - None - } else { - Some(repl.clone()) - }; - let st = d.styles_mut(); - let based = if para { - st.paragraph.retain(|x| x.name != name); - st.paragraph.iter_mut().map(|x| &mut x.based_on).collect::>() - } else { - st.character.retain(|x| x.name != name); - st.character.iter_mut().map(|x| &mut x.based_on).collect() - }; - for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { - *b = parent.clone(); - } - // The replacement keeps its own Export Tagging. - st.export_tags.remove(&export_tag_key(para, &name)); - // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. - redirect_style_refs(d, para, &name, &repl); - ok() - }) -} - -fn create_char(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; - if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) - && let Some(target) = super::text::format_targets_pub(s).first() - { - let st = s.doc()?; - // Match paragraph fromSelection's first-character/caret policy, but keep paragraph - // attributes inherited instead of freezing the fully resolved character properties. - let format = super::text::effective_char_format(s, target)?; - let mut captured = st.doc.styles.char_style_attrs(&format.style); - captured.merge(&format.over); - // Replay the explicit fields so null clears a captured value rather than looking absent. - if let Some(overrides) = p.get("chars").and_then(Value::as_object) { - for (key, value) in overrides { - captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?; - } - } - chars = captured; - } - // Based on [None] is based on nothing. - let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); - s.edit(|d, _| { - if let Some(b) = &based_on - && d.styles.char_style(b).is_none() - { - return Err(bad("style.character.create", format!("no character style `{b}`"))); - } - let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base); - d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() }); - Ok(json!({"name": name})) - }) -} - -fn edit_char(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string(); - if name == designcraft_doc::NO_CHAR_STYLE { - return Err(bad("style.character.edit", "[None] can't be edited")); - } - let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; - // `null` unsets an attribute (the style no longer sets it). - let unset: Vec = p - .get("chars") - .and_then(Value::as_object) - .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) - .unwrap_or_default(); - let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); - let based: Option> = match p.get("basedOn") { - None => None, - Some(Value::Null) => Some(None), - Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None), - Some(Value::String(b)) => Some(Some(b.clone())), - Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")), - }; - s.edit(|d, _| { - if let Some(Some(b)) = &based { - if d.styles.char_style(b).is_none() { - return Err(bad("style.character.edit", format!("no character style `{b}`"))); - } - if d.styles.char_based_on_cycles(&name, b) { - return Err(bad("style.character.edit", "based-on would create a cycle")); - } - } - if let Some(n) = &rename { - if n.is_empty() || n.starts_with('[') { - return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); - } - if d.styles.char_style(n).is_some() { - return Err(bad("style.character.edit", format!("a character style named `{n}` already exists"))); - } - } - let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; - st.chars.merge(&chars); - for k in &unset { - st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?; - } - if let Some(b) = based.clone() { - st.based_on = b; - } - if let Some(n) = &rename { - // Every use: other styles, stories, table cells, nested and GREP styles, footnotes. - rename_style(d, false, &name, n); - } - ok() - }) -} - -fn create_swatch(s: &mut Session, p: &Value) -> Result { - if let Some(g) = p.get("gradient") { - return create_gradient_swatch(s, p, g); - } - let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color (or gradient)"))?; - let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?; - let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| value_name(color)); - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { - color, - color_type: if spot { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }, - }, - locked: false, - named: true, - hidden: false, - }); - Ok(json!({"name": name})) - }) -} - -/// `swatch.create {gradient}`: a named gradient swatch. Stops take a colour swatch by name (as a -/// swatch dragged onto the Gradient panel's stop) or a colour. -fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result { - use designcraft_color::{Gradient, GradientKind, GradientStop}; - const ID: &str = "swatch.create"; - let kind = match g.get("kind").and_then(Value::as_str) { - None | Some("linear") => GradientKind::Linear, - Some("radial") => GradientKind::Radial, - Some(k) => return Err(bad(ID, format!("unknown gradient kind {k}"))), - }; - let doc = &s.doc()?.doc; - let mut stops = Vec::new(); - for st in g.get("stops").and_then(Value::as_array).ok_or_else(|| bad(ID, "a gradient needs stops"))? { - // The stop keeps the swatch, so print export writes it in that swatch's inks. - let (color, swatch) = match (st.get("swatch").and_then(Value::as_str), st.get("color")) { - (Some(n), _) => match doc.swatch(n).map(|w| &w.value) { - Some(SwatchValue::Gradient { .. }) => return Err(bad(ID, format!("`{n}` is a gradient; a stop takes a colour"))), - Some(_) => (doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no color")))?, Some(n.to_string())), - None => return Err(bad(ID, format!("no swatch named `{n}`"))), - }, - (None, Some(c)) => (parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?, None), - (None, None) => return Err(bad(ID, "a stop needs a swatch or a color")), - }; - let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; - stops.push(GradientStop { - offset: pct("location", 0.0), - color, - opacity: pct("opacity", 100.0), - midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), - swatch, - }); - } - if stops.len() < 2 { - return Err(bad(ID, "a gradient needs at least two stops")); - } - let mut gradient = Gradient { kind, stops }; - gradient.sort(); - let name = str_param(p, "name").unwrap_or("New Gradient Swatch").to_string(); - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); - d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); - Ok(json!({"name": name})) - }) -} - -/// `swatch.new`: InDesign's New Swatch. An unnamed colour or gradient (the selection's, or -/// `swatch`) is added to the swatches under a name, and stays applied; a named swatch is -/// duplicated as " copy". -fn new_swatch(s: &mut Session, p: &Value) -> Result { - const ID: &str = "swatch.new"; - let st = s.doc()?; - let name = match str_param(p, "swatch") { - Some(n) => n.to_string(), - None => { - let Some(id) = st.selection.items.first().copied() else { - // Nothing selected: a new colour swatch. - return create_swatch(s, &json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); - }; - let it = st.doc.item(id).ok_or_else(|| bad(ID, "no such item"))?; - if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } - } - }; - let w = st.doc.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch named `{name}`")))?; - if matches!(w.value, SwatchValue::None | SwatchValue::Registration) { - return Err(bad(ID, format!("`{name}` can't be made a new swatch"))); - } - if w.hidden { - // An unnamed gradient takes InDesign's "New Gradient Swatch"; an unnamed colour its values. - let new = match w.value { - SwatchValue::Gradient { .. } => Styles::unique_name(|n| st.doc.swatch(n).is_some(), "New Gradient Swatch"), - _ => name.clone(), - }; - return add_to_swatches(s, &json!({"swatch": name, "name": new})); - } - s.edit(|d, _| { - let copy = Styles::unique_name(|n| d.swatch(n).is_some(), &format!("{name} copy")); - d.swatches.push(Swatch { name: copy.clone(), locked: false, named: true, hidden: false, ..w.clone() }); - Ok(json!({"name": copy})) - }) -} - -pub fn parse_color(v: &Value) -> Option { - use designcraft_color::Color; - match v { - Value::String(s) => Color::from_hex(s), - // `{model, …}` is the colour's own form (as `color.convert` takes it): fractions, never guessed. - Value::Object(o) if o.contains_key("model") => model_color(v), - // One scale per colour, not per component: [1, 128, 255] is 0..255. Only an RGB array whose - // components are all within 0..1 is read as fractions (the older form). - Value::Array(a) if a.len() >= 3 => { - let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; - let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; - Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) - } - // Bare CMYK is always 0..100, as documented: C 1 % is a real tint, so values of 1 or less can't - // mean fractions (#910). - Value::Object(o) => { - if o.contains_key("gray") { - let gray = o.get("gray").and_then(Value::as_f64)?; - return Some(Color::gray(if gray > 1.0 { gray / 100.0 } else { gray } as f32)); - } - let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| (x / 100.0) as f32); - Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) - } - _ => None, - } -} - -/// `{model: rgb|cmyk|gray, …}` with every component in 0..=1; anything else is not a colour. -fn model_color(v: &Value) -> Option { - use designcraft_color::Color; - let color: Color = serde_json::from_value(v.clone()).ok()?; - // A float's last bit (100.0 / 100.0 ≠ 1.0 after an f32 round trip) is not out of range. - let unit = |x: f32| x.is_finite() && (-1e-4..=1.0 + 1e-4).contains(&x); - let q = |x: f32| x.clamp(0.0, 1.0); - match color { - Color::Rgb { r, g, b } => [r, g, b].into_iter().all(unit).then(|| Color::rgb(q(r), q(g), q(b))), - Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().all(unit).then(|| Color::cmyk(q(c), q(m), q(y), q(k))), - Color::Gray { k } => unit(k).then_some(Color::Gray { k: q(k) }), - } -} - -fn apply_object(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string(); - let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?; - let ids = super::targets(s, p)?; - s.edit(|d, _| { - let mut stories = Vec::new(); - for id in &ids { - let Some(it) = d.item_mut(*id) else { continue }; - it.object_style = name.clone(); - os.apply_to(it); - if let Some(tf) = it.text_frame() { - stories.push(tf.story); - } - } - if let Some(ps) = &os.paragraph_style { - for sid in stories { - if let Some(st) = d.story_mut(sid) { - let len = st.len(); - st.format_paras(0..len, |f| f.style = ps.clone()); - } - } - } - Ok(Value::Null) - }) -} - -fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { - let ids = super::targets(s, p)?; - // Each target with its style's attributes applied, kept only where that changes the item. - let d = &s.doc()?.doc; - let changed: Vec = ids - .iter() - .filter_map(|id| { - let it = d.item(*id)?; - let mut next = it.clone(); - d.styles.object_style(&it.object_style)?.apply_to(&mut next); - (next != *it).then_some(next) - }) - .collect(); - if changed.is_empty() { - return Ok(json!({"changed": 0})); - } - s.edit(|d, _| { - for next in &changed { - if let Some(it) = d.item_mut(next.id) { - *it = next.clone(); - } - } - Ok(json!({"changed": changed.len()})) - }) -} - -/// Effect keys accepted at the top level of `style.object.create` / `edit` as shorthand for -/// `effects.`. -const EFFECT_KEYS: [&str; 9] = - ["dropShadow", "feather", "innerShadow", "outerGlow", "innerGlow", "bevel", "satin", "gradientFeather", "directionalFeather"]; - -/// The effects `p` gives (`effects`, then the top-level effect keys) merged field by field over -/// `base`, or `None` when `p` names no effect. -fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result> { - let mut patch = serde_json::Map::new(); - match p.get("effects") { - None | Some(Value::Null) => {} - Some(Value::Object(o)) => patch.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), - Some(_) => return Err(bad(cmd, "`effects` must be an object or null")), - } - for k in EFFECT_KEYS { - if let Some(v) = p.get(k) { - patch.insert(k.to_string(), v.clone()); - } - } - if patch.is_empty() { - return Ok(None); - } - let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; - merge_json(&mut v, Value::Object(patch), 0); - let e: Effects = serde_json::from_value(v).map_err(|e| bad(cmd, format!("effects: {e}")))?; - e.sanitized().map(Some).map_err(|m| bad(cmd, m)) -} - -/// Merges `top` into `base`: objects key by key, anything else replaced. -fn merge_json(base: &mut Value, top: Value, depth: usize) { - match (base, top) { - (Value::Object(b), Value::Object(t)) if depth < 8 => { - for (k, v) in t { - match b.get_mut(&k) { - Some(slot) => merge_json(slot, v, depth + 1), - None => { - b.insert(k, v); - } - } - } - } - (b, t) => *b = t, - } -} - -fn create_object(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); - let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); - let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; - let mut os = ObjectStyle::default(); - if let Some(it) = &src { - os.fill = Some(it.fill.clone()); - os.stroke = Some(it.stroke.clone()); - os.text_frame = it.text_frame().map(|t| t.options.clone()); - os.effects = Some(it.effects.clone()); - } - if let Some(sw) = str_param(p, "fill") { - os.fill = Some(designcraft_doc::Fill::swatch(sw)); - } - if let Some(ps) = str_param(p, "paragraphStyle") { - os.paragraph_style = Some(ps.to_string()); - } - if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? { - os.effects = Some(e); - } - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); - os.name = name.clone(); - d.styles_mut().object.push(os.clone()); - Ok(json!({"name": name})) - }) -} - -fn edit_object(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "style.object.edit"; - let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string(); - if name == designcraft_doc::NO_OBJECT_STYLE { - return Err(bad(CMD, format!("`{name}` can't be edited"))); - } - let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; - let effects = if p.get("effects").is_some_and(Value::is_null) { - if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { - return Err(bad(CMD, "`effects: null` can't be combined with effect settings")); - } - None - } else { - match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { - Some(e) => Some(e), - None => return Ok(json!({"name": name, "updated": 0})), - } - }; - if effects == old.effects { - return Ok(json!({"name": name, "updated": 0})); - } - s.edit(|d, _| { - let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { - return Err(bad(CMD, format!("no object style `{name}`"))); - }; - os.effects = effects.clone(); - // An item follows the style while its effects are the style's. When the style didn't set - // effects before, only items without effects of their own follow (the default styles are - // on most frames). - let follows = old.effects.clone().unwrap_or_default(); - let updated = match &effects { - Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()), - None => 0, - }; - Ok(json!({"name": name, "updated": updated})) - }) -} - -/// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…"). -fn value_name(color: designcraft_color::Color) -> String { - designcraft_color::swatch::value_name(color) -} - -fn load_swatches(s: &mut Session, p: &Value) -> Result { - let bytes = match (str_param(p, "path"), str_param(p, "base64")) { - (_, Some(b)) => super::file::base64_decode(b), - (Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?, - _ => return Err(bad("swatch.load", "give `path` or `base64`")), - }; - let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; - let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); - s.edit(|d, _| { - // The position of the first swatch with each name, kept up to date as the file's colors are added. - let mut at: std::collections::HashMap = std::collections::HashMap::new(); - for (i, x) in d.swatches.iter().enumerate() { - at.entry(x.name.clone()).or_insert(i); - } - let mut names = FileNames::default(); - let (mut added, mut replaced) = (0, 0); - for mut w in incoming { - // An unnamed color is named by its value, and a name the file already gave gets a number. - if w.name.is_empty() - && let SwatchValue::Color { color, .. } = w.value - { - w.name = value_name(color); - w.named = false; - } - w.name = names.unique(std::mem::take(&mut w.name)); - match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { - Some(x) if x.locked => {} - Some(x) => { - if replace { - x.value = w.value; - x.hidden = false; - replaced += 1; - } - } - None => { - at.insert(w.name.clone(), d.swatches.len()); - d.swatches.push(w); - added += 1; - } - } - } - Ok(json!({"added": added, "replaced": replaced})) - }) -} - -/// The swatch names one `.ase` file has given. A name already given gets the first free number -/// ("Red 2"), as [`Styles::unique_name`] numbers names. -#[derive(Default)] -struct FileNames { - taken: std::collections::HashSet, - /// The last number tried for each repeated name. - last: std::collections::HashMap, -} - -impl FileNames { - fn unique(&mut self, base: String) -> String { - if self.taken.insert(base.clone()) { - return base; - } - // Each pass tries the next number, and only finitely many names are taken. - let n = self.last.entry(base.clone()).or_insert(1); - loop { - *n += 1; - let name = format!("{base} {n}"); - if self.taken.insert(name.clone()) { - return name; - } - } - } -} - -fn save_swatches(s: &mut Session, p: &Value) -> Result { - let d = &s.doc()?.doc; - let names: Option> = - p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); - let chosen: Vec = d.swatches.iter().filter(|w| names.as_ref().is_none_or(|n| n.contains(&w.name))).cloned().collect(); - let bytes = designcraft_color::ase::write(&chosen); - match str_param(p, "path") { - Some(path) => { - crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?; - Ok(json!({"path": path, "bytes": bytes.len()})) - } - None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), - } -} - -fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value { - let stops: Vec = g - .stops - .iter() - .map(|s| { - let mut v = json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()}); - if let (Some(o), Some(w)) = (v.as_object_mut(), &s.swatch) { - o.insert("swatch".into(), json!(w)); - } - v - }) - .collect(); - json!({"swatch": name, "kind": if g.kind == designcraft_color::GradientKind::Radial { "radial" } else { "linear" }, "stops": stops}) -} - -fn apply_gradient(s: &mut Session, p: &Value) -> Result { - use designcraft_color::{GradientKind, GradientStop}; - let kind = match str_param(p, "kind") { - Some("radial") => Some(GradientKind::Radial), - Some("linear") => Some(GradientKind::Linear), - Some(k) => return Err(bad("object.gradient", format!("unknown kind {k}"))), - None => None, - }; - let stops = match p.get("stops").and_then(Value::as_array) { - Some(a) => { - let doc = &s.doc()?.doc; - let mut v = Vec::new(); - for st in a { - // A swatch dropped on a stop gives it the swatch's color (a tint's mixed in) and - // its inks for print. - // A stop naming a swatch that's gone keeps the color given with it. - let color = st.get("color").and_then(parse_color); - let (color, swatch) = match st.get("swatch").and_then(Value::as_str) { - Some(name) => { - match doc.resolve_color(name, 1.0).filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none()) { - Some(c) => (c, Some(name.to_string())), - None => (color.ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a color swatch")))?, None), - } - } - None => (color.ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?, None), - }; - let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; - v.push(GradientStop { - offset: pct("location", 0.0), - color, - opacity: pct("opacity", 100.0), - midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), - swatch, - }); - } - if v.len() < 2 { - return Err(bad("object.gradient", "a gradient needs at least two stops")); - } - Some(v) - } - None => None, - }; - let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); - let angle = p.get("angle").and_then(Value::as_f64); - let stroke = match str_param(p, "target") { - None | Some("fill") => false, - Some("stroke") => true, - Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))), - }; - let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to")); - let ids = super::targets(s, p)?; - s.edit(|d, _| { - let mut out = Value::Null; - for id in &ids { - let Some(it) = d.item(*id) else { continue }; - let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }; - let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); - let mut g = cur.clone().unwrap_or_default(); - if let Some(k) = kind { - g.kind = k; - } - if let Some(st) = &stops { - g.stops = st.clone(); - g.sort(); - } - if reverse { - g.reverse(); - } - // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch - // or add an unnamed one. - let name = if cur.as_ref() == Some(&g) { - cur_name - } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) { - w.name.clone() - } else { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient"); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Gradient { gradient: g.clone() }, - locked: false, - named: false, - hidden: true, - }); - name - }; - let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); - let Some(it) = d.item_mut(*id) else { continue }; - // The fill's or the stroke's paint: swatch, tint, angle, vector. - let (swatch, tint, g_angle, g_vector) = if stroke { - (&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector) - } else { - (&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector) - }; - *swatch = name.clone(); - *tint = 1.0; - if let Some(a) = angle { - *g_angle = Some(a); - *g_vector = None; - } - if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { - let (a, b) = (inv * a, inv * b); - if (b - a).hypot() > 1e-6 { - *g_vector = Some([a.x, a.y, b.x, b.y]); - } - } - if out.is_null() { - out = gradient_json(&name, &g); - } - } - Ok(out) - }) -} - -fn apply_color(s: &mut Session, p: &Value) -> Result { - let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; - let stroke = str_param(p, "target") == Some("stroke"); - let named = p.get("ids").is_some() || p.get("id").is_some(); - let text = !named - && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); - // Nothing selected: the colour of new shapes (InDesign's document defaults). - if !text && super::object::nothing_targeted(s, p)? { - return s.edit(|d, _| { - let name = color_swatch(d, color); - let c = &mut d.settings.new_object; - if stroke { - (c.stroke, c.stroke_tint) = (name.clone(), 1.0); - } else { - (c.fill, c.fill_tint) = (name.clone(), 1.0); - } - Ok(json!({"swatch": name, "newObject": true})) - }); - } - let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; - if text && texts.is_empty() { - return Err(bad("object.color", "select the text to colour")); - } - // A caret: the colour is for the next typing (once the swatch exists). - let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); - let r = s.edit(|d, _| { - let name = color_swatch(d, color); - color_items(d, &ids, &name, stroke); - if !caret { - let a = if stroke { - CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } - } else { - CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() } - }; - super::text::apply_char_attrs(d, &texts, &a, &[]); - } - Ok(json!({"swatch": name})) - })?; - if caret && let Some(name) = r.get("swatch").cloned() { - let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) }; - super::text::format_chars(s, &attrs)?; - } - Ok(r) -} - -/// A process swatch holding this colour to the precision its name shows (whole CMYK percents, -/// 0-255 RGB), else a new unnamed one (hidden from the Swatches panel until Add to Swatches): -/// values a fraction of a percent apart would otherwise add hidden "C=0 M=50 Y=100 K=0 2" twins. -fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { - let shown = value_name(color); - let process = |w: &&Swatch| match &w.value { - SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } => Some(*c), - _ => None, - }; - let existing = d - .swatches - .iter() - .find(|w| process(w) == Some(color)) - .or_else(|| { - d.swatches - .iter() - .find(|w| process(w).is_some_and(|c| std::mem::discriminant(&c) == std::mem::discriminant(&color) && value_name(c) == shown)) - }) - .map(|w| w.name.clone()); - if let Some(n) = existing { - return n; - } - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &shown); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, - locked: false, - named: false, - hidden: true, - }); - name -} - -fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { - for id in ids { - if let Some(it) = d.item_mut(*id) { - if stroke { - it.stroke.swatch = name.to_string(); - it.stroke.tint = 1.0; - } else { - it.fill.swatch = name.to_string(); - it.fill.tint = 1.0; - } - } - } -} - -fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) } -} - -/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, -/// as an unnamed colour, fills the selected text (characters), or the selected objects. -fn sample_color(s: &mut Session, p: &Value) -> Result { - const ID: &str = "color.sample"; - let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; - let sr = super::spread_param(p, ID)?; - let st = s.doc()?; - let Some((from, color)) = image_color_at(&st.doc, sr, at) else { - // No opaque image pixel there (an empty part of an image frame, a transparent pixel): the - // Eyedropper applies the clicked object's attributes instead. - return match p.get("matchFrom") { - Some(from) => { - let mut params = p.clone(); - params["from"] = from.clone(); - super::object::match_attributes(s, ¶ms) - } - None => Err(bad(ID, "no placed image under the point")), - }; - }; - let hex = color.to_hex(); - let mut params = p.clone(); - params["color"] = json!(hex); - let mut result = apply_color(s, ¶ms)?; - result["color"] = json!(hex); - result["from"] = json!(from.0); - Ok(result) -} - -/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose -/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); -/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). -pub(crate) fn image_color_at( - d: &designcraft_doc::Document, - sr: designcraft_doc::SpreadRef, - at: designcraft_geom::Point, -) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { - let sp = d.spread(sr)?; - let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); - items.sort_by_key(|i| d.layer_rank(i.layer)); - items - .into_iter() - .rev() - .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) - .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) -} - -fn sample_item( - d: &designcraft_doc::Document, - it: &designcraft_doc::Item, - outer: designcraft_geom::Affine, - at: designcraft_geom::Point, - depth: usize, -) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { - use designcraft_doc::Content; - if it.hidden || depth > 64 { - return None; - } - let xf = outer * it.xf; - match &it.content { - // Only the active state of a multi-state object shows. - Content::Group { .. } => { - let shown: Vec<_> = it.shown_children().collect(); - shown.into_iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)) - } - Content::Graphic(g) => { - let inner = xf.inverse() * at; - if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { - return None; - } - // A clipping path hides the image outside it. - if let Some((region, even_odd)) = g.clip_region() { - let rule = if even_odd { designcraft_geom::FillRule::EvenOdd } else { designcraft_geom::FillRule::NonZero }; - if !designcraft_geom::hit::fill_contains(®ion, rule, inner) { - return None; - } - } - let gp = g.xf.inverse() * inner; - let (w, h) = g.size; - // Written so that NaN (a degenerate transform) is outside too. - if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { - return None; - } - let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; - let img = designcraft_images::decode_rgba(&asset.data)?; - let (pw, ph) = img.dimensions(); - let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); - let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); - let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; - (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) - } - _ => None, - } -} - -fn add_to_swatches(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let name = match str_param(p, "swatch") { - Some(n) => n.to_string(), - None => { - let id = st.selection.items.first().copied().ok_or_else(|| bad("swatch.addToSwatches", "select an object or give `swatch`"))?; - let it = st.doc.item(id).ok_or_else(|| bad("swatch.addToSwatches", "no such item"))?; - if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } - } - }; - let rename = str_param(p, "name").map(str::to_string); - let result = s.edit(|d, _| { - let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; - if w.locked { - return Err(bad("swatch.addToSwatches", "special swatches are already listed")); - } - w.hidden = false; - let Some(new) = rename.filter(|n| *n != name) else { return Ok(json!({"name": name})) }; - if d.swatches.iter().any(|w| w.name == new) { - return Err(bad("swatch.addToSwatches", format!("a swatch named `{new}` exists"))); - } - check_text_nesting(d, "swatch.addToSwatches")?; - let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; - w.name = new.clone(); - w.named = true; - d.rename_swatch_refs(&name, &new); - Ok(json!({"name": new})) - })?; - if let Some(new) = result.get("name").and_then(Value::as_str) { - super::text::rename_typing_swatch(s, &name, new); - } - Ok(result) -} - -/// Renaming a swatch walks every text inside a story (table cells, notes): refuse the whole edit -/// for nesting deeper than 32 levels rather than leave deeper references at a removed name. -fn check_text_nesting(d: &designcraft_doc::Document, id: &str) -> Result<()> { - fn within(st: &designcraft_doc::Story, depth: usize) -> bool { - depth <= 32 - && st.tables.values().all(|t| t.cells.iter().all(|c| within(&c.text, depth + 1))) - && st.notes.iter().chain(&st.endnotes).all(|n| within(&n.text, depth + 1)) - } - if d.stories.values().all(|st| within(st, 0)) { Ok(()) } else { Err(bad(id, "story nesting exceeds 32 levels")) } -} - -/// Swatch Options: rename, Name with Color Value, colour type and value, a tint swatch's tint -/// and base — one undo step. Every use of the swatch follows a rename. -fn swatch_options(s: &mut Session, p: &Value) -> Result { - const ID: &str = "swatch.options"; - let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?.to_string(); - let to = str_param(p, "to").map(|n| n.trim().to_string()); - let with_value = p.get("nameWithValue").and_then(Value::as_bool); - let color = match p.get("color") { - Some(c) => Some(parse_color(c).ok_or_else(|| bad(ID, "bad color"))?), - None => None, - }; - let spot = p.get("spot").and_then(Value::as_bool); - let tint = match p.get("tint") { - Some(v) => Some(v.as_f64().filter(|t| t.is_finite() && (0.0..=1.0).contains(t)).ok_or_else(|| bad(ID, "tint is 0–1"))? as f32), - None => None, - }; - let base = str_param(p, "base").map(str::to_string); - let result = s.edit(|d, _| { - let w = d.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; - let mut w2 = w.clone(); - if w.locked { - if to.as_ref().is_some_and(|t| *t != name) || with_value == Some(true) { - return Err(bad(ID, format!("`{name}` can't be renamed"))); - } - match (&mut w2.value, color) { - (_, None) => {} - (SwatchValue::Paper { color: c }, Some(new)) => *c = new, - _ => return Err(bad(ID, format!("`{name}` can't be edited"))), - } - if spot.is_some() || tint.is_some() || base.is_some() { - return Err(bad(ID, format!("`{name}` can't be edited"))); - } - } else { - match &mut w2.value { - SwatchValue::Color { color: c, color_type } => { - if let Some(new) = color { - *c = new; - } - if let Some(sp) = spot { - *color_type = if sp { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }; - } - if tint.is_some() || base.is_some() { - return Err(bad(ID, "only tint swatches have a tint")); - } - } - SwatchValue::Tint { base: b, tint: t } => { - if color.is_some() || spot.is_some() { - return Err(bad(ID, "a tint swatch takes its colour from its base")); - } - if let Some(v) = tint { - *t = v; - } - if let Some(nb) = &base { - if !d.swatch(nb).is_some_and(|x| matches!(x.value, SwatchValue::Color { .. })) || *nb == name { - return Err(bad(ID, format!("`{nb}` isn't a color swatch"))); - } - *b = nb.clone(); - } - } - _ => { - if color.is_some() || spot.is_some() || tint.is_some() || base.is_some() { - return Err(bad(ID, format!("`{name}` only takes a name here"))); - } - } - } - // Name with Color Value: the name is the colour's values and tracks later edits. - let auto = with_value.unwrap_or(!w.named && to.as_ref().is_none_or(|t| *t == name)); - let derived = match (&w2.value, auto) { - (SwatchValue::Color { color, .. }, true) => Some(value_name(*color)), - _ => None, - }; - match derived { - Some(n) => { - w2.name = n; - w2.named = false; - } - None => { - if let Some(t) = &to { - if t.is_empty() { - return Err(bad(ID, "a swatch needs a name")); - } - w2.name = t.clone(); - } - if with_value == Some(false) || w2.name != name { - w2.named = true; - } - } - } - if w2.name != name && d.swatch(&w2.name).is_some() { - return Err(bad(ID, format!("a swatch named `{}` exists", w2.name))); - } - } - let new = w2.name.clone(); - if new != name { - check_text_nesting(d, ID)?; - } - let slot = d.swatches.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; - *slot = w2; - d.rename_swatch_refs(&name, &new); - d.refresh_gradient_stops(); - Ok(json!({"name": new})) - })?; - if let Some(new) = result.get("name").and_then(Value::as_str) { - super::text::rename_typing_swatch(s, &name, new); - } - Ok(result) -} - -/// #777: the params descriptions name the attributes, and a wrong one is the command's error. -#[cfg(test)] -mod attribute_error_tests { - use super::*; - use crate::cmd::find_command; - - #[test] - fn a_wrong_attribute_names_the_command_and_the_right_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string(); - assert_eq!( - err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})), - "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?" - ); - assert_eq!( - err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})), - "invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?" - ); - assert!( - err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}})) - .starts_with("invalid parameters for `style.character.create`: chars:") - ); - assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes")); - s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap(); - assert!( - err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}})) - .starts_with("invalid parameters for `style.paragraph.edit`: chars:") - ); - s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap(); - assert!( - err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}})) - .starts_with("invalid parameters for `style.character.edit`: chars:") - ); - for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { - let doc = find_command(id).unwrap().params; - assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}"); - } - assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align")); - } -} - -#[cfg(test)] -mod color_tests { - use super::*; - - fn fill_session() -> Session { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); - session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); - session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); - session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); - session.execute("type.char", &json!({"size": 18})).unwrap(); - session.execute("edit.undo", &json!({})).unwrap(); - session - } - - fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { - let before = session.doc().unwrap().clone(); - let journal = session.journal.clone(); - let fill = params - .pointer("/chars/fill") - .or_else(|| params.pointer("/attrs/fill")) - .or_else(|| params.get("fill")) - .and_then(Value::as_str) - .unwrap_or("Missing Ink"); - - let result = session.execute(command, ¶ms); - - match result { - Err(crate::EngineError::BadParams { cmd, msg }) => { - assert_eq!(cmd, command); - assert!(msg.contains(fill), "{msg}"); - assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); - } - other => panic!("expected BadParams for {command}, got {other:?}"), - } - let after = session.doc().unwrap(); - assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); - assert_eq!(before.selection, after.selection); - assert_eq!(before.revision, after.revision); - assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); - assert_eq!(journal, session.journal); - } - - #[test] - fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { - for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { - for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { - let mut session = fill_session(); - assert_fill_rejected(&mut session, "type.char", params); - } - } - } - - #[test] - fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { - for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { - for fill in ["Missing Ink", "#ff0000"] { - let mut session = fill_session(); - session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); - session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); - let mut params = json!({"name": "Existing", "chars": {"fill": fill, "size": 24}}); - // Only the edit commands take `rename`. - if command.ends_with(".edit") { - params["rename"] = json!("Renamed"); - } - assert_fill_rejected(&mut session, command, params); - } - } - } - - #[test] - fn char_fill_preserves_deleted_swatch_when_creating_from_selection() { - for params in - [json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})] - { - let mut session = fill_session(); - let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap(); - let fill = swatch["name"].as_str().unwrap(); - session.execute("type.char", &json!({"fill": fill})).unwrap(); - session.execute("swatch.delete", &json!({"name": fill})).unwrap(); - assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); - - session.execute("style.paragraph.create", ¶ms).unwrap(); - - let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars; - assert_eq!(chars.fill.as_deref(), Some(fill)); - if params.get("chars").is_some() { - assert_eq!(chars.size, Some(24.0)); - } - } - } - - #[test] - fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() { - for fill in ["Missing Ink", "#ff0000"] { - let mut session = fill_session(); - - assert_fill_rejected( - &mut session, - "style.paragraph.create", - json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}), - ); - } - } - - #[test] - fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { - let mut session = fill_session(); - session - .edit(|document, selection| { - let selected = selection.text.unwrap(); - document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); - Ok(()) - }) - .unwrap(); - - session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); - - assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); - } - - #[test] - fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { - for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { - for fill in ["Missing Ink", "#ff0000"] { - let mut session = fill_session(); - assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); - } - } - } - - #[test] - fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { - for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { - let mut session = fill_session(); - if !fill.starts_with('[') { - let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); - assert_eq!(created["name"], fill); - } - for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { - session.execute("type.char", ¶ms).unwrap(); - assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); - } - for kind in ["paragraph", "character"] { - session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); - session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); - let document = &session.doc().unwrap().doc; - for name in ["Colored", "Existing"] { - let attrs = if kind == "paragraph" { - &document.styles.para(name).unwrap().chars - } else { - &document.styles.char_style(name).unwrap().chars - }; - assert_eq!(attrs.fill.as_deref(), Some(fill)); - } - } - assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); - let selected = session.doc().unwrap().selection.text.unwrap(); - let story = session.doc().unwrap().doc.story(selected.story).unwrap(); - assert_eq!(story.text, "Bye"); - assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); - } - } - - #[test] - fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { - for chars in [json!({"size": 18}), json!({"fill": null})] { - let mut session = fill_session(); - session.execute("type.char", &json!({"attrs": chars})).unwrap(); - for kind in ["paragraph", "character"] { - session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); - session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); - } - assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); - } - } - - #[test] - fn percent_components_at_most_one_stay_percent() { - use designcraft_color::Color; - // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. - let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; - assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); - // Fractions take the colour's own form, with its model. - let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"model": "cmyk", "c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; - assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); - // RGB arrays follow the same rule: [1, 128, 255] is 0..255. - let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; - assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); - } - - /// #910: `{c: 1, m: 0, y: 0, k: 0}` is a 1 % cyan tint, not solid cyan, though every component is within 0..1. - #[test] - fn cmyk_percentages_of_one_or_less_stay_percent() { - use designcraft_color::Color; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("swatch.create", &json!({"name": "Tint1", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})).unwrap(); - s.execute("object.fill", &json!({"ids": [id], "swatch": "Tint1"})).unwrap(); - let d = &s.doc().unwrap().doc; - let Some(Color::Cmyk { c, m, y, k }) = d.resolve_color("Tint1", 1.0) else { panic!("not CMYK") }; - assert!((c - 0.01).abs() < 1e-6 && m == 0.0 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); - let pdf = designcraft_pdf::export_pdf(d, &s.cache, &designcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); - let text = String::from_utf8_lossy(&pdf); - // Operands of every `k` (DeviceCMYK fill), last first: [k, y, m, c]. Inks are written in 1/255 steps (C 1 % is 3/255). - let fills: Vec> = - text.split(" k ").filter_map(|before| before.rsplit(' ').take(4).map(|x| x.parse().ok()).collect::>>()).collect(); - let cyan = |want: f32| fills.iter().any(|f| (f[3] - want).abs() < 0.5 / 255.0 + 1e-6 && f[..3] == [0.0; 3]); - assert!(cyan(0.01), "C 1 % in the PDF: {fills:?}"); - assert!(!cyan(1.0), "no solid cyan: {fills:?}"); - // A light tint whose components are all 1 or less, through object.color. - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - s.execute("object.color", &json!({"color": {"c": 0.5, "m": 0.5, "y": 0, "k": 0}})).unwrap(); - let d = &s.doc().unwrap().doc; - let fill = &d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; - let Some(Color::Cmyk { c, m, .. }) = d.resolve_color(fill, 1.0) else { panic!("not CMYK") }; - assert!((c - 0.005).abs() < 1e-6 && (m - 0.005).abs() < 1e-6, "got {c} {m}"); - } - - #[test] - fn a_colour_with_a_model_is_read_as_fractions_without_guessing() { - use designcraft_color::Color; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - let fill = |s: &Session| { - let d = &s.doc().unwrap().doc; - d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() - }; - // M 1 % from the Color panel: not M 100 % (magenta), though every component is within 0..1. - s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 0.01, "y": 0, "k": 0}})).unwrap(); - let Color::Cmyk { c, m, y, k } = fill(&s) else { panic!("not CMYK") }; - assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); - // R 1 of 255 is a near black, not pure red. - s.execute("object.color", &json!({"color": {"model": "rgb", "r": 1.0 / 255.0, "g": 0, "b": 0}})).unwrap(); - let Color::Rgb { r, g, b } = fill(&s) else { panic!("not RGB") }; - assert!((r - 1.0 / 255.0).abs() < 1e-6 && g == 0.0 && b == 0.0, "got {r} {g} {b}"); - // Out of range or not a model: refused rather than guessed. - assert!(s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 50, "y": 0, "k": 0}})).is_err()); - assert!(s.execute("object.color", &json!({"color": {"model": "hsb", "h": 0, "s": 0, "b": 0}})).is_err()); - } - - #[test] - fn colours_that_round_to_the_same_name_share_one_swatch() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - let n = |s: &Session| s.doc().unwrap().doc.swatches.len(); - let first = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.5, "y": 1, "k": 0}})).unwrap(); - let before = n(&s); - for m in [0.501, 0.499, 0.5041] { - let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": m, "y": 1, "k": 0}})).unwrap(); - assert_eq!(r["swatch"], first["swatch"], "M {m}"); - } - s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5, "g": 0.25, "b": 0}})).unwrap(); - s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5005, "g": 0.25, "b": 0}})).unwrap(); - assert_eq!(n(&s), before + 1, "no hidden twins for values a fraction of a step apart"); - // A whole percent apart is a different colour. - let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.51, "y": 1, "k": 0}})).unwrap(); - assert_eq!(r["swatch"], "C=10 M=51 Y=100 K=0"); - } - - #[test] - fn style_groups_rename_every_use() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); - let r = s.execute("style.group", &json!({"kind": "paragraph", "names": ["Head", "Sub"], "group": "Titles"})).unwrap(); - assert_eq!(r["renamed"]["Head"], "Titles/Head"); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].style, "Titles/Head"); - assert_eq!(d.styles.para("Titles/Sub").unwrap().based_on.as_deref(), Some("Titles/Head")); - // Out of the group again. - s.execute("style.group", &json!({"kind": "paragraph", "names": ["Titles/Head"], "group": ""})).unwrap(); - assert!(s.doc().unwrap().doc.styles.para("Head").is_some()); - assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err()); - } - - #[test] - fn character_style_edit_sets_unsets_rebases_and_renames_every_use() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap(); - s.execute( - "style.paragraph.create", - &json!({"name": "Lead", "para": { - "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], - "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], - "nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}), - ) - .unwrap(); - s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - - s.execute( - "style.character.edit", - &json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.styles.char_style("Emphasis").is_none()); - let st = d.styles.char_style("Strong").unwrap(); - assert_eq!(st.based_on.as_deref(), Some("Base")); - // Exactly the attributes set: `null` unset the font style, the rest stayed unset. - assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() }); - let story = d.story(designcraft_doc::StoryId(sid)).unwrap(); - assert!(story.chars.iter().any(|r| r.format.style == "Strong")); - assert!(story.chars.iter().all(|r| r.format.style != "Emphasis")); - let lead = d.styles.para("Lead").unwrap(); - assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(d.footnote_options.ref_char_style, "Strong"); - - // Based On [None] (null or the name) clears it. - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); - - // Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None]. - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); - assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err()); - assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty()); - } - - #[test] - fn continue_numbering_in_a_style_overrides_an_inherited_start_at() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Steps", "para": {"listType": "numbers", "startAt": 5}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "More Steps", "basedOn": "Steps"})).unwrap(); - let start = |s: &Session, name: &str| s.doc().unwrap().doc.styles.resolve_para_style(name).0.start_at; - assert_eq!(start(&s, "More Steps"), Some(5), "inherited"); - s.execute("style.paragraph.edit", &json!({"name": "More Steps", "para": {"startAt": null}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("More Steps").unwrap().para.start_at, Some(None)); - assert_eq!(start(&s, "More Steps"), None, "continues"); - assert_eq!(start(&s, "Steps"), Some(5), "the parent keeps its Start At"); - s.execute("style.paragraph.edit", &json!({"name": "Steps", "para": {"startAt": null}})).unwrap(); - assert_eq!(start(&s, "Steps"), None); - s.execute("style.paragraph.create", &json!({"name": "Fresh", "basedOn": "More Steps", "para": {"start_at": null}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("Fresh").unwrap().para.start_at, Some(None)); - } - - #[test] - fn null_kinsoku_sets_the_default_rules_over_a_based_on_set() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let hard = serde_json::to_value(designcraft_doc::cjk::Kinsoku::named("HardKinsoku").unwrap()).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Base", "para": {"kinsoku": hard}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Body", "basedOn": "Base", "para": {"kinsoku": hard}})).unwrap(); - s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"kinsoku": null}})).unwrap(); - let styles = s.doc().unwrap().doc.styles.clone(); - assert_eq!(styles.para("Body").unwrap().para.kinsoku, Some(None)); - assert_eq!(styles.resolve_para_style("Body").0.kinsoku, None, "Base's set no longer applies"); - // An edit without the attribute keeps it. - s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"rensuuji": false}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().para.kinsoku, Some(None)); - } - - #[test] - fn new_character_style_checks_its_based_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err()); - s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap(); - let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap(); - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); - } - - #[test] - fn swatches_save_and_load_ase() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#108080"})).unwrap(); - let b64 = s.execute("swatch.save", &json!({"names": ["Brand Teal"]})).unwrap()["base64"].as_str().unwrap().to_string(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!(r["added"], 1); - assert!(s.doc().unwrap().doc.swatch("Brand Teal").is_some()); - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); - assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); - } - - #[test] - fn loading_swatches_matches_the_first_swatch_of_each_name() { - use designcraft_color::{Color, ColorType}; - let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; - let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - // Two swatches share a name, and an unnamed color is hidden. - s.edit(|d, _| { - d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]); - Ok(()) - }) - .unwrap(); - let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]); - let b64 = crate::cmd::base64_encode(&file); - let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { - s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() - }; - // With `replace`, the file's second "New" is added as "New 2", only the first "Twin" changes, the hidden - // swatch becomes visible, and the locked [Black] stays. - let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(2), Some(2))); - assert_eq!(named(&s, "New"), [(ink(0.4), false)]); - assert_eq!(named(&s, "New 2"), [(ink(0.5), false)]); - assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); - assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); - assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); - // Without it, every name is already there and nothing changes. - let before = s.doc().unwrap().doc.swatches.clone(); - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); - assert_eq!(s.doc().unwrap().doc.swatches, before); - } - - #[test] - fn loading_swatches_numbers_repeated_names_and_names_unnamed_colors() { - use designcraft_color::{Color, ColorType}; - let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; - let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.edit(|d, _| { - d.swatches.push(sw("Kept", 0.1)); - Ok(()) - }) - .unwrap(); - // "Red 2" comes before the second "Red", which takes "Red 3". Two unnamed colors share a value. - let file = designcraft_color::ase::write(&[ - sw("Red", 0.2), - sw("Red 2", 0.3), - sw("Red", 0.4), - sw("", 0.55), - sw("", 0.55), - sw("", 0.65), - sw("Kept", 0.7), - ]); - let b64 = crate::cmd::base64_encode(&file); - let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { - s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.named)).collect() - }; - // Names already in the document are kept. - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(6), Some(0))); - assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); - assert_eq!(named(&s, "Red 2"), [(ink(0.3), true)]); - assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); - assert_eq!(named(&s, "C=0 M=0 Y=0 K=55"), [(ink(0.55), false)]); - assert_eq!(named(&s, "C=0 M=0 Y=0 K=55 2"), [(ink(0.55), false)]); - assert_eq!(named(&s, "C=0 M=0 Y=0 K=65"), [(ink(0.65), false)]); - assert_eq!(named(&s, "Kept"), [(ink(0.1), true)]); - assert!(named(&s, "").is_empty()); - // With `replace`, the same file again replaces each of its colors once. - let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(7))); - assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); - assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); - assert_eq!(named(&s, "Kept"), [(ink(0.7), true)]); - } - - #[test] - fn unnamed_colors_then_add_to_swatches() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - let listed = |s: &Session| s.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).count(); - let before = listed(&s); - let r = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); - assert_eq!(r["swatch"], "C=10 M=20 Y=30 K=0"); - assert_eq!(listed(&s), before, "unnamed: not in the Swatches panel"); - // The same colour again reuses it; the stroke target works too. - let n = s.doc().unwrap().doc.swatches.len(); - s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}, "target": "stroke"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.swatches.len(), n); - let d = &s.doc().unwrap().doc; - let it = d.item(designcraft_doc::ItemId(id)).unwrap(); - assert_eq!(it.fill.swatch, it.stroke.swatch); - // Add to Swatches with a name: listed, and the object follows the rename. - s.execute("swatch.addToSwatches", &json!({"name": "Sand"})).unwrap(); - assert_eq!(listed(&s), before + 1); - let d = &s.doc().unwrap().doc; - assert_eq!(d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); - // A colour equal to an existing swatch applies that swatch. - s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); - } - - #[test] - fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { - use std::sync::Arc; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); - s.execute("endnote.insert", &json!({"text": "end"})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); - s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); - let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); - // The same unnamed colour on a cell, the cell's text and the footnote's text. - s.edit(|d, _| { - let st = d.story_mut(sid).unwrap(); - let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); - t.cells[0].fill = old.clone(); - t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); - Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); - Arc::make_mut(&mut st.endnotes[0]).text.chars[0].format.over.underline_color = Some(old.clone()); - Ok(()) - }) - .unwrap(); - s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); - let d = &s.doc().unwrap().doc; - let st = d.story(sid).unwrap(); - assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); - let cell = &st.tables.values().next().unwrap().cells[0]; - assert_eq!(cell.fill, "Sand"); - assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); - assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); - assert_eq!(st.endnotes[0].text.chars[0].format.over.underline_color.as_deref(), Some("Sand")); - d.check().unwrap(); - } -} - #[cfg(test)] -mod group_sample_tests { - use serde_json::json; - - use crate::Session; - - /// Grouping the sample's table styles keeps its table cells styled (no missing styles). - #[test] - fn grouping_reaches_table_cells() { - let mut s = Session::new(); - s.execute("file.newSample", &json!({})).unwrap(); - let before = s.execute("preflight.run", &json!({})).unwrap()["errors"].as_u64().unwrap(); - s.execute("style.group", &json!({"kind": "paragraph", "names": ["Table Head", "Table Body"], "group": "Tables"})).unwrap(); - let after = s.execute("preflight.run", &json!({})).unwrap(); - assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}"); - } -} - -#[cfg(test)] -mod color_group_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn color_groups_hold_swatches_and_round_trip() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = s.execute("swatch.create", &json!({"name": "Brand Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - let b = s.execute("swatch.create", &json!({"name": "Brand Blue", "color": {"c": 100, "m": 60, "y": 0, "k": 0}})).unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - let g = s.execute("swatch.newColorGroup", &json!({"name": "Brand", "swatches": [a]})).unwrap(); - assert_eq!(g["name"], "Brand"); - assert!(s.execute("swatch.newColorGroup", &json!({"swatches": ["[Black]"]})).is_err(), "special swatches stay at the top"); - s.execute("swatch.moveToGroup", &json!({"swatches": [b], "group": "Brand"})).unwrap(); - let groups = |s: &Session| s.doc().unwrap().doc.color_groups.clone(); - assert_eq!(groups(&s)[0].swatches, [a.clone(), b.clone()]); - let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); - let back = designcraft_idml::import_idml(&bytes).unwrap(); - assert_eq!(back.color_groups, groups(&s), "IDML ColorGroup"); - s.execute("swatch.delete", &json!({"name": b})).unwrap(); - assert_eq!(groups(&s)[0].swatches, [a.as_str()]); - s.execute("swatch.renameColorGroup", &json!({"name": "Brand", "to": "Identity"})).unwrap(); - s.execute("swatch.ungroupColorGroup", &json!({"name": "Identity"})).unwrap(); - assert!(groups(&s).is_empty()); - assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches"); - } -} - -#[cfg(test)] -mod ink_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn ink_manager_converts_and_aliases_spots_in_pdf() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for (n, c) in [("Pantone A", [0, 50, 100, 0]), ("Pantone B", [100, 0, 0, 0])] { - s.execute("swatch.create", &json!({"name": n, "spot": true, "color": {"c": c[0], "m": c[1], "y": c[2], "k": c[3]}})).unwrap(); - } - let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "Pantone A", "ids": [id]})).unwrap(); - let pdf = |s: &mut Session| { - let b = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - String::from_utf8_lossy(&super::super::file::base64_decode(&b)).into_owned() - }; - assert!(pdf(&mut s).contains("Pantone"), "a separation"); - s.execute("ink.options", &json!({"ink": "Pantone A", "alias": "Pantone B"})).unwrap(); - let p = pdf(&mut s); - assert!(p.contains("Pantone#20B") || p.contains("Pantone B"), "aliased onto B"); - assert!(!p.contains("Pantone#20A") && !p.contains("/Pantone A")); - s.execute("ink.options", &json!({"allToProcess": true})).unwrap(); - assert!(!pdf(&mut s).contains("Pantone"), "all spots to process"); - let l = s.execute("ink.list", &json!({})).unwrap(); - assert_eq!(l["inks"][0]["alias"], "Pantone B"); - let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - assert!(designcraft_pdf::inks::spot_output(&back, &designcraft_pdf::inks::ink_set(&back), "Pantone A").1, "IDML ConvertToProcess"); - assert!(back.inks.aliases.iter().any(|(a, b)| a == "Pantone A" && b == "Pantone B"), "IDML AliasInkName"); - assert!(s.execute("ink.options", &json!({"ink": "[Black]", "toProcess": true})).is_err()); - assert!(s.execute("ink.options", &json!({"ink": "Pantone B", "alias": "Pantone A"})).is_err(), "an alias loop is refused"); - } -} - -#[cfg(test)] -mod export_tag_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn export_tagging_shapes_epub_markup() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Chapter Title", "chars": {"size": 24}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap(); - s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap(); - assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err()); - // Split Document is for paragraph styles; it alone keeps the tagging. - assert!(s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "split": true})).is_err()); - s.execute("style.paragraph.create", &json!({"name": "Part"})).unwrap(); - assert_eq!(s.execute("style.exportTag", &json!({"style": "Part", "split": true})).unwrap()["split"], true); - assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_some_and(|t| t.split)); - s.execute("style.exportTag", &json!({"style": "Part", "split": false})).unwrap(); - assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_none(), "an untouched tagging goes"); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Chapter Title"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 10, "focus": 14})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Key Term"})).unwrap(); - let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(html.contains("

Origins

"), "{html}"); - assert!(html.contains("word"), "{html}"); - // Tagged PDF: a heading element and a paragraph element. - let r = s.execute("file.exportPdf", &json!({"tagged": true, "compressImages": false})).unwrap(); - let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - let text = String::from_utf8_lossy(&pdf); - assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); - assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); - } - - #[test] - fn export_tagging_follows_a_renamed_style_and_goes_with_a_deleted_one() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); - s.execute("style.character.create", &json!({"name": "Term"})).unwrap(); - s.execute("style.exportTag", &json!({"style": "Head", "tag": "h2", "class": "head"})).unwrap(); - s.execute("style.exportTag", &json!({"style": "Body", "tag": "p", "class": "body"})).unwrap(); - s.execute("style.exportTag", &json!({"style": "Term", "character": true, "tag": "em", "class": "term"})).unwrap(); - let tag = |s: &Session, name: &str, character: bool| { - s.doc().unwrap().doc.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone())) - }; - let pair = |t: &str, c: &str| Some((t.to_string(), c.to_string())); - // A rename onto another style's name is refused, so neither tagging is lost. - assert!(s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Body"})).is_err()); - assert_eq!((tag(&s, "Head", false), tag(&s, "Body", false)), (pair("h2", "head"), pair("p", "body"))); - - s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Title"})).unwrap(); - s.execute("style.character.edit", &json!({"name": "Term", "rename": "Word"})).unwrap(); - assert_eq!(tag(&s, "Title", false), pair("h2", "head")); - assert_eq!(tag(&s, "Head", false), None); - assert_eq!(tag(&s, "Word", true), pair("em", "term")); - assert_eq!(tag(&s, "Term", true), None); - // Moving a style into a group renames it to Group/Name. - s.execute("style.group", &json!({"kind": "paragraph", "names": ["Title"], "group": "Heads"})).unwrap(); - assert_eq!(tag(&s, "Heads/Title", false), pair("h2", "head")); - assert_eq!(tag(&s, "Title", false), None); - - for _ in 0..3 { - s.execute("edit.undo", &json!({})).unwrap(); - } - assert_eq!(tag(&s, "Head", false), pair("h2", "head")); - assert_eq!(tag(&s, "Title", false), None); - assert_eq!(tag(&s, "Term", true), pair("em", "term")); - assert_eq!(tag(&s, "Word", true), None); - - // Deleting drops the deleted style's tagging; the replacement keeps its own. - s.execute("style.paragraph.delete", &json!({"name": "Head", "replaceWith": "Body"})).unwrap(); - assert_eq!(tag(&s, "Head", false), None); - assert_eq!(tag(&s, "Body", false), pair("p", "body")); - s.execute("style.character.delete", &json!({"name": "Term"})).unwrap(); - assert_eq!(tag(&s, "Term", true), None); - assert!(s.doc().unwrap().doc.styles.export_tags.keys().all(|k| k == "p:Body")); - } - - #[test] - fn a_rename_onto_a_taken_name_says_so_and_a_trimmed_rename_drops_a_stale_tag() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for (kind, a, b) in [("paragraph", "Head", "Body"), ("character", "Term", "Word")] { - s.execute(&format!("style.{kind}.create"), &json!({"name": a})).unwrap(); - s.execute(&format!("style.{kind}.create"), &json!({"name": b})).unwrap(); - let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": b})).unwrap_err().to_string(); - assert!(e.contains(&format!("a {kind} style named `{b}` already exists")), "{e}"); - let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": "[None]"})).unwrap_err().to_string(); - assert!(e.contains("can't rename to `[None]`"), "{e}"); - } - // An entry left under a name no style has doesn't attach to a style renamed onto it. - s.edit(|d, _| { - d.styles_mut() - .export_tags - .insert("p:Title".into(), designcraft_doc::ExportTag { tag: "h6".into(), class: "stale".into(), ..Default::default() }); - Ok(()) - }) - .unwrap(); - s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": " Title "})).unwrap(); - let st = &s.doc().unwrap().doc.styles; - assert!(st.para("Title").is_some(), "the new name is trimmed"); - assert_eq!(st.export_tag("Title", false), None); - } -} - -#[cfg(test)] -mod cjk_tests { - use super::*; - #[test] - fn cjk_composite_font_command_invalidates_layout_and_undo_restores_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 150], "content": "text", "text": "AB"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let definition = |scale| json!({"name": "Mixed", "entries": [{"family": "Source Serif 4", "relativeSize": scale}]}); - s.execute("style.compositeFont.set", &definition(1.0)).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 2})).unwrap(); - s.execute("type.char", &json!({"fontFamily": "Mixed", "leadingAki": 0.25, "jidori": 4})).unwrap(); - let old = s.cache.get(&s.doc().unwrap().doc, sid, None); - s.execute("style.compositeFont.set", &definition(2.0)).unwrap(); - let new = s.cache.get(&s.doc().unwrap().doc, sid, None); - assert!(!std::sync::Arc::ptr_eq(&old, &new)); - assert!(new.frames[0].lines[0].end_x > old.frames[0].lines[0].end_x); - s.execute("edit.undo", &json!({})).unwrap(); - let restored = s.cache.get(&s.doc().unwrap().doc, sid, None); - assert_eq!(old.frames[0].lines[0].end_x, restored.frames[0].lines[0].end_x); - assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); - } -} - -#[cfg(test)] -mod delete_tests { - use designcraft_doc::{CellStyle, ObjectStyle, StoryId, Toc, TocEntry}; - use serde_json::json; - - use crate::Session; - - /// Is `name` still named anywhere in the document? - fn named(s: &Session, name: &str) -> bool { - serde_json::to_string(&*s.doc().unwrap().doc).unwrap().contains(&format!("\"{name}\"")) - } - - fn char_doc() -> (Session, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"tracking": 20}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Child", "basedOn": "Emphasis"})).unwrap(); - s.execute("style.character.create", &json!({"name": "Strong", "chars": {"size": 14}})).unwrap(); - let refs = json!({ - "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], - "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], - "nestedLineStyles": [{"style": "Emphasis", "lines": 1}], - "dropCapLines": 2, "dropCapChars": 1, "dropCapStyle": "Emphasis"}); - s.execute("style.paragraph.create", &json!({"name": "Lead", "para": refs})).unwrap(); - s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); - let sid = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap()["story"] - .as_u64() - .unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - // Local paragraph overrides name it too. - s.execute("type.para", &json!({"attrs": refs})).unwrap(); - s.edit(|d, _| { - d.styles_mut().default_character = "Emphasis".into(); - Ok(()) - }) - .unwrap(); - assert!(named(&s, "Emphasis")); - (s, sid) - } - - #[test] - fn deleting_a_character_style_points_every_use_at_the_replacement() { - let (mut s, sid) = char_doc(); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Strong"})).unwrap(); - assert!(!named(&s, "Emphasis"), "no dangling reference"); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Strong")); - assert_eq!(d.story(StoryId(sid)).unwrap().chars[0].format.style, "Strong"); - let lead = &d.styles.para("Lead").unwrap().para; - assert_eq!(lead.nested_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.grep_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.drop_cap_style.as_deref(), Some("Strong")); - assert_eq!(d.footnote_options.ref_char_style, "Strong"); - assert_eq!(d.styles.default_character, "Strong"); - // One undo step brings the style and every use back. - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").is_some()); - assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().chars[0].format.style, "Emphasis"); - } - - #[test] - fn deleting_a_character_style_for_none_unsets_based_on() { - let (mut s, _) = char_doc(); - s.execute("style.character.delete", &json!({"name": "Emphasis"})).unwrap(); - assert!(!named(&s, "Emphasis"), "no dangling reference"); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.styles.char_style("Child").unwrap().based_on, None); - assert_eq!(d.footnote_options.ref_char_style, designcraft_doc::NO_CHAR_STYLE); - } - - /// Replacing a style with its own descendant rebases the deleted style's children on its - /// parent, so no based-on chain becomes a cycle. - #[test] - fn replacing_a_style_with_its_child_makes_no_cycle() { - let (mut s, _) = char_doc(); - s.execute("style.character.create", &json!({"name": "Grandchild", "basedOn": "Child"})).unwrap(); - s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Grandchild"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Base")); - assert_eq!(d.styles.char_style("Grandchild").unwrap().based_on.as_deref(), Some("Child")); - assert!(!named(&s, "Emphasis")); - } - - #[test] - fn bad_style_deletes_are_errors_and_change_nothing() { - let (mut s, _) = char_doc(); - s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - for (id, p) in [ - ("style.character.delete", json!({})), - ("style.character.delete", json!({"name": ""})), - ("style.character.delete", json!({"name": "Nope"})), - ("style.character.delete", json!({"name": "[None]"})), - ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Nope"})), - ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Emphasis"})), - ("style.paragraph.delete", json!({})), - ("style.paragraph.delete", json!({"name": ""})), - ("style.paragraph.delete", json!({"name": "Nope"})), - ("style.paragraph.delete", json!({"name": "[Basic Paragraph]"})), - ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Nope"})), - ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Head"})), - ] { - assert!(s.execute(id, &p).is_err(), "{id} {p}"); - } - assert_eq!(*s.doc().unwrap().doc, *before); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - } - - #[test] - fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { - let mut s = Session::new(); - s.execute("file.newSample", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Table Body", "nextStyle": "Table Body"})).unwrap(); - s.edit(|d, _| { - let story = *d.stories.keys().next().ok_or(crate::EngineError::NoDocument)?; - let st = d.styles_mut(); - st.default_paragraph = "Table Body".into(); - st.object.push(ObjectStyle { name: "Boxed".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); - st.cell.push(CellStyle { name: "Body Cell".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); - d.footnote_options.para_style = "Table Body".into(); - d.endnote_options.para_style = "Table Body".into(); - d.endnote_options.heading_style = "Table Body".into(); - d.settings.epub_split = designcraft_doc::EpubSplit::Style("Table Body".into()); - d.toc = Some(Toc { - story, - title: "Contents".into(), - entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], - page_numbers: false, - title_style: Some("Table Body".into()), - }); - Ok(()) - }) - .unwrap(); - assert!(named(&s, "Table Body")); - s.execute("style.paragraph.delete", &json!({"name": "Table Body", "replaceWith": "Body"})).unwrap(); - assert!(!named(&s, "Table Body"), "no dangling reference"); - let d = s.doc().unwrap().doc.clone(); - let sub = d.styles.para("Sub").unwrap(); - assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body"))); - assert_eq!(d.styles.default_paragraph, "Body"); - assert_eq!(d.endnote_options.heading_style, "Body"); - assert_eq!(d.settings.epub_split, designcraft_doc::EpubSplit::Style("Body".into()), "EPUB Split Document follows the replacement"); - let after = s.execute("preflight.run", &json!({})).unwrap(); - assert!(!after.to_string().contains("Table Body"), "{after}"); - } -} - -#[cfg(test)] -mod swatch_options_tests { - use designcraft_color::SwatchValue; - use designcraft_doc::{ItemId, StoryId}; - use serde_json::json; - - use crate::Session; - - /// A document whose frame, text, styles, tint swatch and colour group all use "Brand". - fn brand_doc() -> (Session, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("swatch.create", &json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 100, "k": 0}})).unwrap(); - s.execute("swatch.create", &json!({"name": "Other", "color": "#108080"})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); - let (id, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - s.execute("object.fill", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); - s.execute("object.stroke", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - s.execute("type.char", &json!({"attrs": {"fill": "Brand"}})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - s.execute("table.select", &json!({"what": "table"})).unwrap(); - s.execute("table.setCell", &json!({"stroke": {"weight": 2, "type": "dashed", "gapColor": "Brand", "edges": ["all"]}})).unwrap(); - s.execute("swatch.newColorGroup", &json!({"name": "Brand Colors", "swatches": ["Brand"]})).unwrap(); - s.execute("style.character.create", &json!({"name": "Accent"})).unwrap(); - s.edit(|d, _| { - d.swatches.push(designcraft_color::Swatch { - name: "Brand 50%".into(), - value: SwatchValue::Tint { base: "Brand".into(), tint: 0.5 }, - locked: false, - named: true, - hidden: false, - }); - let st = d.styles_mut(); - if let Some(p) = st.para_mut(designcraft_doc::BASIC_PARAGRAPH) { - p.chars.underline_color = Some("Brand".into()); - p.para.shading_color = Some("Brand".into()); - p.para.border_gap_color = Some("Brand".into()); - p.chars.ruby_stroke = Some("Brand".into()); - } - if let Some(c) = st.char_style_mut("Accent") { - c.chars.fill = Some("Brand".into()); - c.chars.kenten_fill = Some("Brand".into()); - } - Ok(()) - }) - .unwrap(); - (s, id, sid) - } - - fn uses(s: &Session, id: u64, sid: u64, name: &str) -> Vec { - let d = &s.doc().unwrap().doc; - let it = d.item(ItemId(id)).unwrap(); - let story = d.story(StoryId(sid)).unwrap(); - let basic = d.styles.para(designcraft_doc::BASIC_PARAGRAPH).unwrap(); - vec![ - it.fill.swatch == name, - it.stroke.swatch == name, - story.chars.iter().any(|r| r.format.over.fill.as_deref() == Some(name)), - basic.chars.underline_color.as_deref() == Some(name), - basic.para.shading_color.as_deref() == Some(name), - d.styles.char_style("Accent").unwrap().chars.fill.as_deref() == Some(name), - matches!(&d.swatch("Brand 50%").unwrap().value, SwatchValue::Tint { base, .. } if base == name), - d.color_groups[0].swatches == [name], - basic.para.border_gap_color.as_deref() == Some(name), - basic.chars.ruby_stroke.as_deref() == Some(name), - d.styles.char_style("Accent").unwrap().chars.kenten_fill.as_deref() == Some(name), - story.tables.values().any(|t| t.cells.iter().all(|c| c.strokes.iter().all(|e| e.gap_color == name))), - ] - } - - #[test] - fn renaming_a_swatch_renames_every_use_in_one_undo_step() { - let (mut s, id, sid) = brand_doc(); - assert!(uses(&s, id, sid, "Brand").iter().all(|u| *u)); - let r = s.execute("swatch.options", &json!({"name": "Brand", "to": "Signal Orange", "spot": true})).unwrap(); - assert_eq!(r["name"], "Signal Orange"); - let d = &s.doc().unwrap().doc; - assert!(d.swatch("Brand").is_none()); - assert!(matches!(d.swatch("Signal Orange").unwrap().value, SwatchValue::Color { color_type: designcraft_color::ColorType::Spot, .. })); - assert_eq!(uses(&s, id, sid, "Signal Orange"), vec![true; 12]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.swatch("Brand").is_some()); - assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); - } - - #[test] - fn bad_swatch_renames_change_nothing() { - let (mut s, id, sid) = brand_doc(); - let before = s.doc().unwrap().doc.clone(); - for p in [ - json!({"name": "Brand", "to": "Other"}), - json!({"name": "Brand", "to": " "}), - json!({"name": "Brand", "to": "[Paper]"}), - json!({"name": "[Black]", "to": "Ink"}), - json!({"name": "[Registration]", "to": "Reg"}), - json!({"name": "[None]", "to": "Nothing"}), - json!({"name": "[Black]", "color": "#ff0000"}), - json!({"name": "Brand 50%", "color": "#ff0000"}), - json!({"name": "Missing", "to": "X"}), - ] { - assert!(s.execute("swatch.options", &p).is_err(), "{p}"); - } - assert_eq!(s.doc().unwrap().doc.swatches, before.swatches); - assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); - // [Paper]'s colour can change; its name can't. - s.execute("swatch.options", &json!({"name": "[Paper]", "to": "[Paper]", "color": "#fff8e0"})).unwrap(); - } - - #[test] - fn name_with_color_value_tracks_the_colour() { - let (mut s, id, sid) = brand_doc(); - let r = s.execute("swatch.options", &json!({"name": "Brand", "nameWithValue": true, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); - assert_eq!(r["name"], "C=10 M=20 Y=30 K=40"); - assert_eq!(uses(&s, id, sid, "C=10 M=20 Y=30 K=40"), vec![true; 12]); - // Later colour edits keep the name in step until the swatch is named by hand. - let r = s.execute("swatch.options", &json!({"name": "C=10 M=20 Y=30 K=40", "color": {"c": 0, "m": 0, "y": 0, "k": 50}})).unwrap(); - assert_eq!(r["name"], "C=0 M=0 Y=0 K=50"); - let r = s.execute("swatch.options", &json!({"name": "C=0 M=0 Y=0 K=50", "to": "Warm Gray"})).unwrap(); - assert_eq!(r["name"], "Warm Gray"); - assert!(s.doc().unwrap().doc.swatch("Warm Gray").unwrap().named); - } -} - -#[cfg(test)] -mod grep_style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn grep_style_pattern_that_cannot_compile_is_rejected() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); - let bad = json!([{"style": "Keep", "pattern": "(\\d+"}]); - let e = s.execute("style.paragraph.create", &json!({"name": "Broken", "para": {"grepStyles": bad}})).unwrap_err().to_string(); - assert!(e.contains("bad GREP") && e.contains("(\\\\d+"), "{e}"); - assert!(s.doc().unwrap().doc.styles.para("Broken").is_none()); - - // The lookbehind dialect compiles. - let good = json!([{"style": "Keep", "pattern": "(?<=\\s)[aiouwzAIOUWZ]\\s"}]); - s.execute("style.paragraph.create", &json!({"name": "Polish", "para": {"grepStyles": good}})).unwrap(); - let e = s.execute("style.paragraph.edit", &json!({"name": "Polish", "para": {"grepStyles": bad, "spaceAfter": 6}})).unwrap_err().to_string(); - assert!(e.contains("bad GREP"), "{e}"); - let st = s.doc().unwrap().doc.styles.para("Polish").unwrap().clone(); - assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "(?<=\\s)[aiouwzAIOUWZ]\\s"); - assert_eq!(st.para.space_after, None, "nothing changes"); - - // A paragraph override too. - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ala i kot"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - let e = s.execute("type.para", &json!({"grepStyles": bad})).unwrap_err().to_string(); - assert!(e.contains("bad GREP"), "{e}"); - } - - #[test] - fn idml_import_keeps_a_broken_grep_style_and_warns() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Broken"})).unwrap(); - // Written past the command, as another application could. - s.edit(|d, _| { - if let Some(st) = d.styles_mut().para_mut("Broken") { - st.para.grep_styles = Some(vec![designcraft_doc::GrepStyle { style: "Keep".into(), pattern: "[a-".into() }]); - } - Ok(()) - }) - .unwrap(); - let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - let r = s.execute("file.openIdml", &json!({"base64": b64})).unwrap(); - let warnings = r["warnings"].to_string(); - assert!(warnings.contains("Broken") && warnings.contains("[a-") && warnings.contains("bad GREP"), "{warnings}"); - let st = s.doc().unwrap().doc.styles.para("Broken").unwrap().clone(); - assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "[a-", "the style opens with its pattern"); - } -} - -#[cfg(test)] -mod sample_color_tests { - use designcraft_color::{Color, SwatchValue}; - use designcraft_doc::ItemId; - use serde_json::json; - - use crate::Session; - - /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. - fn session_with_image() -> (Session, u64) { - let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let b64 = super::super::file::base64_encode(&png); - let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); - (s, r["id"].as_u64().unwrap()) - } - - fn swatch_color(s: &Session, name: &str) -> Color { - match &s.doc().unwrap().doc.swatch(name).unwrap().value { - SwatchValue::Color { color, .. } => *color, - other => panic!("{other:?}"), - } - } - - #[test] - fn the_eyedropper_colours_selected_text_with_the_image_under_it() { - let (mut s, image) = session_with_image(); - let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); - let fid = ItemId(f["id"].as_u64().unwrap()); - let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); - assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - let name = r["swatch"].as_str().unwrap().to_string(); - assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); - assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); - let st = s.doc().unwrap().doc.story(story).unwrap().clone(); - assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); - assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); - // The frame itself is not coloured. - assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); - } - - #[test] - fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { - let (mut s, _) = session_with_image(); - let lid = s.doc().unwrap().doc.layers[0].id.0; - s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); - s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); - let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); - assert_eq!(r["color"], "#ff0000", "{r}"); - let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); - assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); - // Sampling the same colour again reuses its swatch. - let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); - assert_eq!(again["swatch"], r["swatch"]); - } - - #[test] - fn a_caret_takes_the_sample_for_typing_and_nothing_to_sample_is_refused() { - let (mut s, _) = session_with_image(); - let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); - let fid = ItemId(f["id"].as_u64().unwrap()); - let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); - // A caret: the sampled colour is for the next typing. - let r = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], r["swatch"]); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing"); - s.execute("text.insert", &json!({"text": "X"})).unwrap(); - let st = s.doc().unwrap().doc.story(story).unwrap().clone(); - assert_eq!(st.format_after(1).over.fill.as_deref(), r["swatch"].as_str(), "{r}"); - assert_eq!(st.format_after(0).over.fill, None, "the text before the caret keeps its colour"); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); - let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); - assert!(none.contains("no placed image"), "{none}"); - // Just past the image's right edge. - assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - } - - #[test] - fn where_no_image_pixel_shows_the_clicked_frame_is_matched_instead() { - let (mut s, image) = session_with_image(); - let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [image]})).unwrap(); - // The frame reaches 72 pt below its image, and a clipping path shows the left (red) pixel only. - s.edit(|d, _| { - let it = d.item_mut(ItemId(image)).ok_or(crate::EngineError::NoDocument)?; - let b = it.inner_bounds(); - it.path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1 + 72.0)); - let designcraft_doc::Content::Graphic(g) = &mut it.content else { return Err(crate::EngineError::NoDocument) }; - let left = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, g.size.0 / 2.0, g.size.1)); - g.clip = Some(std::sync::Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, left))); - Ok(()) - }) - .unwrap(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - let r = s.execute("color.sample", &json!({"at": [100, 100], "matchFrom": image})).unwrap(); - assert_eq!(r["color"], "#ff0000", "inside the clipping path the image is sampled"); - // The clipped-out blue pixel and the empty frame below the image: nothing to sample, so the - // frame's attributes apply. - for at in [[180, 100], [100, 180]] { - assert!(s.execute("color.sample", &json!({"at": at})).is_err(), "{at:?}"); - s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [rect]})).unwrap(); - s.execute("color.sample", &json!({"at": at, "matchFrom": image})).unwrap(); - assert_eq!(s.doc().unwrap().doc.item(ItemId(rect)).unwrap().fill.swatch, "[Black]", "{at:?}"); - } - } - - #[test] - fn sampling_and_explicit_colour_use_the_same_text_application_path() { - for command in ["color.sample", "object.color"] { - // Each command gets its own key: the eyedropper samples at `at`, object.color takes `color`. - let params = |at: [f64; 2], color: &str, extra: serde_json::Value| { - let mut p = if command == "color.sample" { json!({"at": at}) } else { json!({"color": color}) }; - if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { - p.extend(extra.clone()); - } - p - }; - let (mut s, _) = session_with_image(); - let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); - let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let r = s.execute(command, ¶ms([100.0, 100.0], "#ff0000", json!({}))).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); - s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); - let text = s.doc().unwrap().doc.story(story).unwrap(); - assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); - // Explicit ids override text targeting, including with a text selection active. - let fid = ItemId(f["id"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); - let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"id": fid.0}))).unwrap(); - assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); - let text = s.doc().unwrap().doc.story(story).unwrap(); - assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); - let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"target": "stroke"}))).unwrap(); - let text = s.doc().unwrap().doc.story(story).unwrap(); - assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); - } - } - - #[test] - fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { - let (mut s, _) = session_with_image(); - let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [line]})).unwrap(); - let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); - let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); - assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); - assert!(it.fill.is_none(), "the fill stays empty"); - } -} - -#[cfg(test)] -mod radial_gradient_tests { - use serde_json::json; - - /// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on - /// a 200×240 page, rendered at 1 px/pt. - fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) { - (img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0]) - } - - #[test] - fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); - s.execute( - "object.gradient", - &json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let mut r = designcraft_render::Renderer::new(); - let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}"); - // The PDF, placed back and drawn: the same ellipse. - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200})) - .unwrap(); - let d = p.doc().unwrap().doc.clone(); - let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); - } - - #[test] - fn a_box_with_no_height_or_width_has_a_circle_across_its_length() { - let f = designcraft_doc::Fill::swatch("g"); - let line = designcraft_geom::Rect::new(20.0, 60.0, 180.0, 60.0); - let (c, rx, ry) = f.radial_ellipse(line); - assert_eq!((c.x, c.y, rx, ry), (100.0, 60.0, 80.0, 80.0)); - let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(10.0, 0.0, 10.0, 50.0)); - assert_eq!((rx, ry), (25.0, 25.0)); - // A point: a tiny circle, never a zero radius. - let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(5.0, 5.0, 5.0, 5.0)); - assert!(rx > 0.0 && rx == ry); - // A box keeps its ellipse. - let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)); - assert_eq!((rx, ry), (50.0, 100.0)); - } -} - -#[cfg(test)] -mod gradient_swatch_tests { - use designcraft_color::SwatchValue; - use serde_json::json; - - fn doc() -> crate::Session { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s - } - - #[test] - fn a_gradient_swatch_from_colour_swatches() { - let mut s = doc(); - // As the Gradient panel's stops take swatches dragged onto them. - let r = s - .execute( - "swatch.create", - &json!({"gradient": {"stops": [{"location": 100, "swatch": "C=100 M=0 Y=0 K=0"}, {"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}]}}), - ) - .unwrap(); - assert_eq!(r["name"], "New Gradient Swatch"); - let d = &s.doc().unwrap().doc; - let w = d.swatch("New Gradient Swatch").unwrap(); - assert!(w.named && !w.hidden); - let SwatchValue::Gradient { gradient } = &w.value else { panic!("{w:?}") }; - assert_eq!(gradient.stops[0].color, d.resolve_color("C=0 M=100 Y=0 K=0", 1.0).unwrap(), "sorted: magenta first"); - assert_eq!(gradient.stops[1].color, d.resolve_color("C=100 M=0 Y=0 K=0", 1.0).unwrap()); - // A second one gets its own name; bad stops are refused. - let r = s - .execute( - "swatch.create", - &json!({"gradient": {"kind": "radial", "stops": [{"color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}}), - ) - .unwrap(); - assert_eq!(r["name"], "New Gradient Swatch 2"); - for bad in [ - json!({"stops": [{"swatch": "C=0 M=100 Y=0 K=0"}]}), - json!({"stops": [{"swatch": "Nope"}, {"swatch": "[Black]"}]}), - json!({"stops": [{"swatch": "New Gradient Swatch"}, {"swatch": "[Black]"}]}), - json!({"kind": "conic", "stops": [{"color": "#000000"}, {"color": "#ffffff"}]}), - ] { - assert!(s.execute("swatch.create", &json!({"gradient": bad})).is_err(), "{bad}"); - } - } - - #[test] - fn gradient_stops_follow_their_swatch() { - let mut s = doc(); - s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap(); - s.execute( - "swatch.create", - &json!({"name": "G", "gradient": {"stops": [{"location": 0, "swatch": "Gold"}, {"location": 100, "swatch": "[Paper]"}]}}), - ) - .unwrap(); - let stops = |s: &crate::Session| match &s.doc().unwrap().doc.swatch("G").unwrap().value { - SwatchValue::Gradient { gradient } => gradient.stops.clone(), - v => panic!("{v:?}"), - }; - assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold")); - assert_eq!(stops(&s)[1].swatch.as_deref(), Some("[Paper]")); - // Renamed and edited: the stop names the new name and shows the new color. - s.execute("swatch.options", &json!({"name": "Gold", "to": "Gold 871", "color": {"c": 0, "m": 20, "y": 80, "k": 20}})).unwrap(); - assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold 871")); - assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2)); - // Deleted: the stop keeps its color as a plain one. - s.execute("swatch.delete", &json!({"name": "Gold 871"})).unwrap(); - assert_eq!(stops(&s)[0].swatch, None); - assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2)); - } - - #[test] - fn new_swatch_names_the_selections_gradient_or_duplicates_a_swatch() { - let mut s = doc(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - // A gradient made in the Gradient panel is unnamed (and not listed) … - s.execute("object.gradient", &json!({"ids": [id], "stops": [{"location": 0, "color": {"c": 0, "m": 100, "y": 0, "k": 0}}, {"location": 100, "color": {"c": 100, "m": 0, "y": 0, "k": 0}}]})).unwrap(); - let fill = |s: &crate::Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); - assert!(s.doc().unwrap().doc.swatch(&fill(&s)).unwrap().hidden); - // … until New Swatch: "New Gradient Swatch", listed, still the object's fill. - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch"); - assert_eq!(fill(&s), "New Gradient Swatch"); - let w = s.doc().unwrap().doc.swatch("New Gradient Swatch").unwrap().clone(); - assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Gradient { .. })); - // A named swatch: a copy. - assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch copy"); - assert_eq!(s.execute("swatch.new", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy"); - assert!(s.execute("swatch.new", &json!({"swatch": "[None]"})).is_err()); - // The stroke in front: the stroke's swatch. - s.execute("object.stroke", &json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); - assert_eq!(s.execute("swatch.new", &json!({"target": "stroke"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy 2"); - // Nothing selected, no swatch given: a new colour swatch. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let n = s.execute("swatch.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); - let w = s.doc().unwrap().doc.swatch(&n).unwrap(); - assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Color { .. }), "{w:?}"); - } -} - -#[cfg(test)] -mod gradient_stroke_tests { - use serde_json::json; - - /// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end. - fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) { - (img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0]) - } - - #[test] - fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); - let r = s - .execute( - "object.gradient", - &json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let it = d.item(designcraft_doc::ItemId(id)).unwrap(); - assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap()); - assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone"); - let mut rr = designcraft_render::Renderer::new(); - let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}"); - // The PDF, placed back and drawn: the same. - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200})) - .unwrap(); - let pd = p.doc().unwrap().doc.clone(); - let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}"); - // The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left. - s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(a > 215 && b < 40, "dragged: {a} {b}"); - assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); - } - - /// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF. - fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); - let mut g = g; - g["ids"] = json!([id]); - g["target"] = json!("stroke"); - s.execute("object.gradient", &g).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap(); - let pd = p.doc().unwrap().doc.clone(); - let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap(); - (screen, placed) - } - - #[test] - fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() { - // The line's box has no height: the gradient is a circle across the line's length. - let (screen, pdf) = line_with_stroke_gradient( - json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), - ); - for (what, img) in [("screen", &screen), ("pdf", &pdf)] { - let (a, m, b) = along(img); - assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}"); - } - } - - #[test] - fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() { - let (screen, pdf) = - line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})); - let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60)); - // The line's box has no height: the gradient runs across the line's length, so the stroke - // shows its middle. - assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}"); - assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}"); - } -} +mod color_tests; #[cfg(test)] #[path = "text_color_tests.rs"] diff --git a/crates/engine/src/cmd/style/color.rs b/crates/engine/src/cmd/style/color.rs new file mode 100644 index 0000000000..ebefbd0f31 --- /dev/null +++ b/crates/engine/src/cmd/style/color.rs @@ -0,0 +1,332 @@ +use super::swatches::{parse_color, value_name}; +use super::*; + +fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value { + let stops: Vec = g + .stops + .iter() + .map(|s| { + let mut v = json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()}); + if let (Some(o), Some(w)) = (v.as_object_mut(), &s.swatch) { + o.insert("swatch".into(), json!(w)); + } + v + }) + .collect(); + json!({"swatch": name, "kind": if g.kind == designcraft_color::GradientKind::Radial { "radial" } else { "linear" }, "stops": stops}) +} + +pub(super) fn apply_gradient(s: &mut Session, p: &Value) -> Result { + use designcraft_color::{GradientKind, GradientStop}; + let kind = match str_param(p, "kind") { + Some("radial") => Some(GradientKind::Radial), + Some("linear") => Some(GradientKind::Linear), + Some(k) => return Err(bad("object.gradient", format!("unknown kind {k}"))), + None => None, + }; + let stops = match p.get("stops").and_then(Value::as_array) { + Some(a) => { + let doc = &s.doc()?.doc; + let mut v = Vec::new(); + for st in a { + // A swatch dropped on a stop gives it the swatch's color (a tint's mixed in) and + // its inks for print. + // A stop naming a swatch that's gone keeps the color given with it. + let color = st.get("color").and_then(parse_color); + let (color, swatch) = match st.get("swatch").and_then(Value::as_str) { + Some(name) => { + match doc.resolve_color(name, 1.0).filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none()) { + Some(c) => (c, Some(name.to_string())), + None => (color.ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a color swatch")))?, None), + } + } + None => (color.ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?, None), + }; + let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; + v.push(GradientStop { + offset: pct("location", 0.0), + color, + opacity: pct("opacity", 100.0), + midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), + swatch, + }); + } + if v.len() < 2 { + return Err(bad("object.gradient", "a gradient needs at least two stops")); + } + Some(v) + } + None => None, + }; + let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let angle = p.get("angle").and_then(Value::as_f64); + let stroke = match str_param(p, "target") { + None | Some("fill") => false, + Some("stroke") => true, + Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))), + }; + let (from, to) = (crate::cmd::point_param(p, "from"), crate::cmd::point_param(p, "to")); + let ids = crate::cmd::targets(s, p)?; + s.edit(|d, _| { + let mut out = Value::Null; + for id in &ids { + let Some(it) = d.item(*id) else { continue }; + let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }; + let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); + let mut g = cur.clone().unwrap_or_default(); + if let Some(k) = kind { + g.kind = k; + } + if let Some(st) = &stops { + g.stops = st.clone(); + g.sort(); + } + if reverse { + g.reverse(); + } + // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch + // or add an unnamed one. + let name = if cur.as_ref() == Some(&g) { + cur_name + } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) { + w.name.clone() + } else { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient"); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Gradient { gradient: g.clone() }, + locked: false, + named: false, + hidden: true, + }); + name + }; + let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); + let Some(it) = d.item_mut(*id) else { continue }; + // The fill's or the stroke's paint: swatch, tint, angle, vector. + let (swatch, tint, g_angle, g_vector) = if stroke { + (&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector) + } else { + (&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector) + }; + *swatch = name.clone(); + *tint = 1.0; + if let Some(a) = angle { + *g_angle = Some(a); + *g_vector = None; + } + if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { + let (a, b) = (inv * a, inv * b); + if (b - a).hypot() > 1e-6 { + *g_vector = Some([a.x, a.y, b.x, b.y]); + } + } + if out.is_null() { + out = gradient_json(&name, &g); + } + } + Ok(out) + }) +} + +pub(super) fn apply_color(s: &mut Session, p: &Value) -> Result { + let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; + let stroke = str_param(p, "target") == Some("stroke"); + let named = p.get("ids").is_some() || p.get("id").is_some(); + let text = !named + && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); + // Nothing selected: the colour of new shapes (InDesign's document defaults). + if !text && crate::cmd::object::nothing_targeted(s, p)? { + return s.edit(|d, _| { + let name = color_swatch(d, color); + let c = &mut d.settings.new_object; + if stroke { + (c.stroke, c.stroke_tint) = (name.clone(), 1.0); + } else { + (c.fill, c.fill_tint) = (name.clone(), 1.0); + } + Ok(json!({"swatch": name, "newObject": true})) + }); + } + let (ids, texts) = if text { (vec![], crate::cmd::text::format_targets_pub(s)) } else { (crate::cmd::targets(s, p)?, vec![]) }; + if text && texts.is_empty() { + return Err(bad("object.color", "select the text to colour")); + } + // A caret: the colour is for the next typing (once the swatch exists). + let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); + let r = s.edit(|d, _| { + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); + if !caret { + let a = if stroke { + CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } + } else { + CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() } + }; + crate::cmd::text::apply_char_attrs(d, &texts, &a, &[]); + } + Ok(json!({"swatch": name})) + })?; + if caret && let Some(name) = r.get("swatch").cloned() { + let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) }; + crate::cmd::text::format_chars(s, &attrs)?; + } + Ok(r) +} + +/// A process swatch holding this colour to the precision its name shows (whole CMYK percents, +/// 0-255 RGB), else a new unnamed one (hidden from the Swatches panel until Add to Swatches): +/// values a fraction of a percent apart would otherwise add hidden "C=0 M=50 Y=100 K=0 2" twins. +fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { + let shown = value_name(color); + let process = |w: &&Swatch| match &w.value { + SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } => Some(*c), + _ => None, + }; + let existing = d + .swatches + .iter() + .find(|w| process(w) == Some(color)) + .or_else(|| { + d.swatches + .iter() + .find(|w| process(w).is_some_and(|c| std::mem::discriminant(&c) == std::mem::discriminant(&color) && value_name(c) == shown)) + }) + .map(|w| w.name.clone()); + if let Some(n) = existing { + return n; + } + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &shown); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name +} + +fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { + for id in ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.to_string(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.to_string(); + it.fill.tint = 1.0; + } + } + } +} + +pub(super) fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { crate::cmd::has_selection(s) } +} + +/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, +/// as an unnamed colour, fills the selected text (characters), or the selected objects. +pub(super) fn sample_color(s: &mut Session, p: &Value) -> Result { + const ID: &str = "color.sample"; + let at = crate::cmd::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; + let sr = crate::cmd::spread_param(p, ID)?; + let st = s.doc()?; + let Some((from, color)) = image_color_at(&st.doc, sr, at) else { + // No opaque image pixel there (an empty part of an image frame, a transparent pixel): the + // Eyedropper applies the clicked object's attributes instead. + return match p.get("matchFrom") { + Some(from) => { + let mut params = p.clone(); + params["from"] = from.clone(); + crate::cmd::object::match_attributes(s, ¶ms) + } + None => Err(bad(ID, "no placed image under the point")), + }; + }; + let hex = color.to_hex(); + let mut params = p.clone(); + params["color"] = json!(hex); + let mut result = apply_color(s, ¶ms)?; + result["color"] = json!(hex); + result["from"] = json!(from.0); + Ok(result) +} + +/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose +/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); +/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). +pub(crate) fn image_color_at( + d: &designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + at: designcraft_geom::Point, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + let sp = d.spread(sr)?; + let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| d.layer_rank(i.layer)); + items + .into_iter() + .rev() + .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) +} + +fn sample_item( + d: &designcraft_doc::Document, + it: &designcraft_doc::Item, + outer: designcraft_geom::Affine, + at: designcraft_geom::Point, + depth: usize, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + use designcraft_doc::Content; + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + match &it.content { + // Only the active state of a multi-state object shows. + Content::Group { .. } => { + let shown: Vec<_> = it.shown_children().collect(); + shown.into_iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)) + } + Content::Graphic(g) => { + let inner = xf.inverse() * at; + if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { + return None; + } + // A clipping path hides the image outside it. + if let Some((region, even_odd)) = g.clip_region() { + let rule = if even_odd { designcraft_geom::FillRule::EvenOdd } else { designcraft_geom::FillRule::NonZero }; + if !designcraft_geom::hit::fill_contains(®ion, rule, inner) { + return None; + } + } + let gp = g.xf.inverse() * inner; + let (w, h) = g.size; + // Written so that NaN (a degenerate transform) is outside too. + if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { + return None; + } + let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; + let img = designcraft_images::decode_rgba(&asset.data)?; + let (pw, ph) = img.dimensions(); + let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); + let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); + let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; + (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) + } + _ => None, + } +} + +/// Renaming a swatch walks every text inside a story (table cells, notes): refuse the whole edit +/// for nesting deeper than 32 levels rather than leave deeper references at a removed name. +pub(super) fn check_text_nesting(d: &designcraft_doc::Document, id: &str) -> Result<()> { + fn within(st: &designcraft_doc::Story, depth: usize) -> bool { + depth <= 32 + && st.tables.values().all(|t| t.cells.iter().all(|c| within(&c.text, depth + 1))) + && st.notes.iter().chain(&st.endnotes).all(|n| within(&n.text, depth + 1)) + } + if d.stories.values().all(|st| within(st, 0)) { Ok(()) } else { Err(bad(id, "story nesting exceeds 32 levels")) } +} diff --git a/crates/engine/src/cmd/style/color_tests.rs b/crates/engine/src/cmd/style/color_tests.rs new file mode 100644 index 0000000000..499f985d2e --- /dev/null +++ b/crates/engine/src/cmd/style/color_tests.rs @@ -0,0 +1,533 @@ +use super::*; + +fn fill_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); + session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); + session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); + session.execute("type.char", &json!({"size": 18})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + session +} + +fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let fill = params + .pointer("/chars/fill") + .or_else(|| params.pointer("/attrs/fill")) + .or_else(|| params.get("fill")) + .and_then(Value::as_str) + .unwrap_or("Missing Ink"); + + let result = session.execute(command, ¶ms); + + match result { + Err(crate::EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, command); + assert!(msg.contains(fill), "{msg}"); + assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); + } + other => panic!("expected BadParams for {command}, got {other:?}"), + } + let after = session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(before.selection, after.selection); + assert_eq!(before.revision, after.revision); + assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); + assert_eq!(journal, session.journal); +} + +#[test] +fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { + for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { + for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "type.char", params); + } + } +} + +#[test] +fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { + for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); + let mut params = json!({"name": "Existing", "chars": {"fill": fill, "size": 24}}); + // Only the edit commands take `rename`. + if command.ends_with(".edit") { + params["rename"] = json!("Renamed"); + } + assert_fill_rejected(&mut session, command, params); + } + } +} + +#[test] +fn char_fill_preserves_deleted_swatch_when_creating_from_selection() { + for params in [json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})] { + let mut session = fill_session(); + let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap(); + let fill = swatch["name"].as_str().unwrap(); + session.execute("type.char", &json!({"fill": fill})).unwrap(); + session.execute("swatch.delete", &json!({"name": fill})).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + + session.execute("style.paragraph.create", ¶ms).unwrap(); + + let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars; + assert_eq!(chars.fill.as_deref(), Some(fill)); + if params.get("chars").is_some() { + assert_eq!(chars.size, Some(24.0)); + } + } +} + +#[test] +fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + + assert_fill_rejected( + &mut session, + "style.paragraph.create", + json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}), + ); + } +} + +#[test] +fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + + assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); +} + +#[test] +fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { + for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); + } + } +} + +#[test] +fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { + for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { + let mut session = fill_session(); + if !fill.starts_with('[') { + let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); + assert_eq!(created["name"], fill); + } + for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { + session.execute("type.char", ¶ms).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + } + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); + let document = &session.doc().unwrap().doc; + for name in ["Colored", "Existing"] { + let attrs = + if kind == "paragraph" { &document.styles.para(name).unwrap().chars } else { &document.styles.char_style(name).unwrap().chars }; + assert_eq!(attrs.fill.as_deref(), Some(fill)); + } + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); + let selected = session.doc().unwrap().selection.text.unwrap(); + let story = session.doc().unwrap().doc.story(selected.story).unwrap(); + assert_eq!(story.text, "Bye"); + assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); + } +} + +#[test] +fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { + for chars in [json!({"size": 18}), json!({"fill": null})] { + let mut session = fill_session(); + session.execute("type.char", &json!({"attrs": chars})).unwrap(); + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); + } +} + +#[test] +fn percent_components_at_most_one_stay_percent() { + use designcraft_color::Color; + // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. + let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; + assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); + // Fractions take the colour's own form, with its model. + let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"model": "cmyk", "c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; + assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); + // RGB arrays follow the same rule: [1, 128, 255] is 0..255. + let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; + assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); +} + +/// #910: `{c: 1, m: 0, y: 0, k: 0}` is a 1 % cyan tint, not solid cyan, though every component is within 0..1. +#[test] +fn cmyk_percentages_of_one_or_less_stay_percent() { + use designcraft_color::Color; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("swatch.create", &json!({"name": "Tint1", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})).unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "Tint1"})).unwrap(); + let d = &s.doc().unwrap().doc; + let Some(Color::Cmyk { c, m, y, k }) = d.resolve_color("Tint1", 1.0) else { panic!("not CMYK") }; + assert!((c - 0.01).abs() < 1e-6 && m == 0.0 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &designcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); + let text = String::from_utf8_lossy(&pdf); + // Operands of every `k` (DeviceCMYK fill), last first: [k, y, m, c]. Inks are written in 1/255 steps (C 1 % is 3/255). + let fills: Vec> = + text.split(" k ").filter_map(|before| before.rsplit(' ').take(4).map(|x| x.parse().ok()).collect::>>()).collect(); + let cyan = |want: f32| fills.iter().any(|f| (f[3] - want).abs() < 0.5 / 255.0 + 1e-6 && f[..3] == [0.0; 3]); + assert!(cyan(0.01), "C 1 % in the PDF: {fills:?}"); + assert!(!cyan(1.0), "no solid cyan: {fills:?}"); + // A light tint whose components are all 1 or less, through object.color. + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.color", &json!({"color": {"c": 0.5, "m": 0.5, "y": 0, "k": 0}})).unwrap(); + let d = &s.doc().unwrap().doc; + let fill = &d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let Some(Color::Cmyk { c, m, .. }) = d.resolve_color(fill, 1.0) else { panic!("not CMYK") }; + assert!((c - 0.005).abs() < 1e-6 && (m - 0.005).abs() < 1e-6, "got {c} {m}"); +} + +#[test] +fn a_colour_with_a_model_is_read_as_fractions_without_guessing() { + use designcraft_color::Color; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let fill = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() + }; + // M 1 % from the Color panel: not M 100 % (magenta), though every component is within 0..1. + s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 0.01, "y": 0, "k": 0}})).unwrap(); + let Color::Cmyk { c, m, y, k } = fill(&s) else { panic!("not CMYK") }; + assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); + // R 1 of 255 is a near black, not pure red. + s.execute("object.color", &json!({"color": {"model": "rgb", "r": 1.0 / 255.0, "g": 0, "b": 0}})).unwrap(); + let Color::Rgb { r, g, b } = fill(&s) else { panic!("not RGB") }; + assert!((r - 1.0 / 255.0).abs() < 1e-6 && g == 0.0 && b == 0.0, "got {r} {g} {b}"); + // Out of range or not a model: refused rather than guessed. + assert!(s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 50, "y": 0, "k": 0}})).is_err()); + assert!(s.execute("object.color", &json!({"color": {"model": "hsb", "h": 0, "s": 0, "b": 0}})).is_err()); +} + +#[test] +fn colours_that_round_to_the_same_name_share_one_swatch() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let n = |s: &Session| s.doc().unwrap().doc.swatches.len(); + let first = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.5, "y": 1, "k": 0}})).unwrap(); + let before = n(&s); + for m in [0.501, 0.499, 0.5041] { + let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": m, "y": 1, "k": 0}})).unwrap(); + assert_eq!(r["swatch"], first["swatch"], "M {m}"); + } + s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5, "g": 0.25, "b": 0}})).unwrap(); + s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5005, "g": 0.25, "b": 0}})).unwrap(); + assert_eq!(n(&s), before + 1, "no hidden twins for values a fraction of a step apart"); + // A whole percent apart is a different colour. + let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.51, "y": 1, "k": 0}})).unwrap(); + assert_eq!(r["swatch"], "C=10 M=51 Y=100 K=0"); +} + +#[test] +fn style_groups_rename_every_use() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + let r = s.execute("style.group", &json!({"kind": "paragraph", "names": ["Head", "Sub"], "group": "Titles"})).unwrap(); + assert_eq!(r["renamed"]["Head"], "Titles/Head"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].style, "Titles/Head"); + assert_eq!(d.styles.para("Titles/Sub").unwrap().based_on.as_deref(), Some("Titles/Head")); + // Out of the group again. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Titles/Head"], "group": ""})).unwrap(); + assert!(s.doc().unwrap().doc.styles.para("Head").is_some()); + assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err()); +} + +#[test] +fn character_style_edit_sets_unsets_rebases_and_renames_every_use() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap(); + s.execute( + "style.paragraph.create", + &json!({"name": "Lead", "para": { + "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], + "nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}), + ) + .unwrap(); + s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + + s.execute( + "style.character.edit", + &json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.styles.char_style("Emphasis").is_none()); + let st = d.styles.char_style("Strong").unwrap(); + assert_eq!(st.based_on.as_deref(), Some("Base")); + // Exactly the attributes set: `null` unset the font style, the rest stayed unset. + assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() }); + let story = d.story(designcraft_doc::StoryId(sid)).unwrap(); + assert!(story.chars.iter().any(|r| r.format.style == "Strong")); + assert!(story.chars.iter().all(|r| r.format.style != "Emphasis")); + let lead = d.styles.para("Lead").unwrap(); + assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(d.footnote_options.ref_char_style, "Strong"); + + // Based On [None] (null or the name) clears it. + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); + + // Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None]. + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); + assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err()); + assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty()); +} + +#[test] +fn continue_numbering_in_a_style_overrides_an_inherited_start_at() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Steps", "para": {"listType": "numbers", "startAt": 5}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "More Steps", "basedOn": "Steps"})).unwrap(); + let start = |s: &Session, name: &str| s.doc().unwrap().doc.styles.resolve_para_style(name).0.start_at; + assert_eq!(start(&s, "More Steps"), Some(5), "inherited"); + s.execute("style.paragraph.edit", &json!({"name": "More Steps", "para": {"startAt": null}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("More Steps").unwrap().para.start_at, Some(None)); + assert_eq!(start(&s, "More Steps"), None, "continues"); + assert_eq!(start(&s, "Steps"), Some(5), "the parent keeps its Start At"); + s.execute("style.paragraph.edit", &json!({"name": "Steps", "para": {"startAt": null}})).unwrap(); + assert_eq!(start(&s, "Steps"), None); + s.execute("style.paragraph.create", &json!({"name": "Fresh", "basedOn": "More Steps", "para": {"start_at": null}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Fresh").unwrap().para.start_at, Some(None)); +} + +#[test] +fn null_kinsoku_sets_the_default_rules_over_a_based_on_set() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let hard = serde_json::to_value(designcraft_doc::cjk::Kinsoku::named("HardKinsoku").unwrap()).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Base", "para": {"kinsoku": hard}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "basedOn": "Base", "para": {"kinsoku": hard}})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"kinsoku": null}})).unwrap(); + let styles = s.doc().unwrap().doc.styles.clone(); + assert_eq!(styles.para("Body").unwrap().para.kinsoku, Some(None)); + assert_eq!(styles.resolve_para_style("Body").0.kinsoku, None, "Base's set no longer applies"); + // An edit without the attribute keeps it. + s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"rensuuji": false}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().para.kinsoku, Some(None)); +} + +#[test] +fn new_character_style_checks_its_based_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err()); + s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap(); + let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap(); + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); +} + +#[test] +fn swatches_save_and_load_ase() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#108080"})).unwrap(); + let b64 = s.execute("swatch.save", &json!({"names": ["Brand Teal"]})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!(r["added"], 1); + assert!(s.doc().unwrap().doc.swatch("Brand Teal").is_some()); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); + assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); +} + +#[test] +fn loading_swatches_matches_the_first_swatch_of_each_name() { + use designcraft_color::{Color, ColorType}; + let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; + let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Two swatches share a name, and an unnamed color is hidden. + s.edit(|d, _| { + d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]); + Ok(()) + }) + .unwrap(); + let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]); + let b64 = crate::cmd::base64_encode(&file); + let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { + s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() + }; + // With `replace`, the file's second "New" is added as "New 2", only the first "Twin" changes, the hidden + // swatch becomes visible, and the locked [Black] stays. + let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(2), Some(2))); + assert_eq!(named(&s, "New"), [(ink(0.4), false)]); + assert_eq!(named(&s, "New 2"), [(ink(0.5), false)]); + assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); + assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); + assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); + // Without it, every name is already there and nothing changes. + let before = s.doc().unwrap().doc.swatches.clone(); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); + assert_eq!(s.doc().unwrap().doc.swatches, before); +} + +#[test] +fn loading_swatches_numbers_repeated_names_and_names_unnamed_colors() { + use designcraft_color::{Color, ColorType}; + let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; + let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.edit(|d, _| { + d.swatches.push(sw("Kept", 0.1)); + Ok(()) + }) + .unwrap(); + // "Red 2" comes before the second "Red", which takes "Red 3". Two unnamed colors share a value. + let file = + designcraft_color::ase::write(&[sw("Red", 0.2), sw("Red 2", 0.3), sw("Red", 0.4), sw("", 0.55), sw("", 0.55), sw("", 0.65), sw("Kept", 0.7)]); + let b64 = crate::cmd::base64_encode(&file); + let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { + s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.named)).collect() + }; + // Names already in the document are kept. + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(6), Some(0))); + assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); + assert_eq!(named(&s, "Red 2"), [(ink(0.3), true)]); + assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=55"), [(ink(0.55), false)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=55 2"), [(ink(0.55), false)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=65"), [(ink(0.65), false)]); + assert_eq!(named(&s, "Kept"), [(ink(0.1), true)]); + assert!(named(&s, "").is_empty()); + // With `replace`, the same file again replaces each of its colors once. + let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(7))); + assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); + assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); + assert_eq!(named(&s, "Kept"), [(ink(0.7), true)]); +} + +#[test] +fn unnamed_colors_then_add_to_swatches() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let listed = |s: &Session| s.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).count(); + let before = listed(&s); + let r = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); + assert_eq!(r["swatch"], "C=10 M=20 Y=30 K=0"); + assert_eq!(listed(&s), before, "unnamed: not in the Swatches panel"); + // The same colour again reuses it; the stroke target works too. + let n = s.doc().unwrap().doc.swatches.len(); + s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}, "target": "stroke"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.swatches.len(), n); + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.fill.swatch, it.stroke.swatch); + // Add to Swatches with a name: listed, and the object follows the rename. + s.execute("swatch.addToSwatches", &json!({"name": "Sand"})).unwrap(); + assert_eq!(listed(&s), before + 1); + let d = &s.doc().unwrap().doc; + assert_eq!(d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); + // A colour equal to an existing swatch applies that swatch. + s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); +} + +#[test] +fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { + use std::sync::Arc; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); + s.execute("endnote.insert", &json!({"text": "end"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); + s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); + let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); + // The same unnamed colour on a cell, the cell's text and the footnote's text. + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); + t.cells[0].fill = old.clone(); + t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); + Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); + Arc::make_mut(&mut st.endnotes[0]).text.chars[0].format.over.underline_color = Some(old.clone()); + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(sid).unwrap(); + assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); + let cell = &st.tables.values().next().unwrap().cells[0]; + assert_eq!(cell.fill, "Sand"); + assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); + assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); + assert_eq!(st.endnotes[0].text.chars[0].format.over.underline_color.as_deref(), Some("Sand")); + d.check().unwrap(); +} diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs new file mode 100644 index 0000000000..e00eca1b73 --- /dev/null +++ b/crates/engine/src/cmd/style/object.rs @@ -0,0 +1,164 @@ +use super::*; + +pub(super) fn apply_object(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string(); + let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?; + let ids = crate::cmd::targets(s, p)?; + s.edit(|d, _| { + let mut stories = Vec::new(); + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + it.object_style = name.clone(); + os.apply_to(it); + if let Some(tf) = it.text_frame() { + stories.push(tf.story); + } + } + if let Some(ps) = &os.paragraph_style { + for sid in stories { + if let Some(st) = d.story_mut(sid) { + let len = st.len(); + st.format_paras(0..len, |f| f.style = ps.clone()); + } + } + } + Ok(Value::Null) + }) +} + +pub(super) fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { + let ids = crate::cmd::targets(s, p)?; + // Each target with its style's attributes applied, kept only where that changes the item. + let d = &s.doc()?.doc; + let changed: Vec = ids + .iter() + .filter_map(|id| { + let it = d.item(*id)?; + let mut next = it.clone(); + d.styles.object_style(&it.object_style)?.apply_to(&mut next); + (next != *it).then_some(next) + }) + .collect(); + if changed.is_empty() { + return Ok(json!({"changed": 0})); + } + s.edit(|d, _| { + for next in &changed { + if let Some(it) = d.item_mut(next.id) { + *it = next.clone(); + } + } + Ok(json!({"changed": changed.len()})) + }) +} + +/// Effect keys accepted at the top level of `style.object.create` / `edit` as shorthand for +/// `effects.`. +const EFFECT_KEYS: [&str; 9] = + ["dropShadow", "feather", "innerShadow", "outerGlow", "innerGlow", "bevel", "satin", "gradientFeather", "directionalFeather"]; + +/// The effects `p` gives (`effects`, then the top-level effect keys) merged field by field over +/// `base`, or `None` when `p` names no effect. +fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result> { + let mut patch = serde_json::Map::new(); + match p.get("effects") { + None | Some(Value::Null) => {} + Some(Value::Object(o)) => patch.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), + Some(_) => return Err(bad(cmd, "`effects` must be an object or null")), + } + for k in EFFECT_KEYS { + if let Some(v) = p.get(k) { + patch.insert(k.to_string(), v.clone()); + } + } + if patch.is_empty() { + return Ok(None); + } + let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + merge_json(&mut v, Value::Object(patch), 0); + let e: Effects = serde_json::from_value(v).map_err(|e| bad(cmd, format!("effects: {e}")))?; + e.sanitized().map(Some).map_err(|m| bad(cmd, m)) +} + +/// Merges `top` into `base`: objects key by key, anything else replaced. +fn merge_json(base: &mut Value, top: Value, depth: usize) { + match (base, top) { + (Value::Object(b), Value::Object(t)) if depth < 8 => { + for (k, v) in t { + match b.get_mut(&k) { + Some(slot) => merge_json(slot, v, depth + 1), + None => { + b.insert(k, v); + } + } + } + } + (b, t) => *b = t, + } +} + +pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); + let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); + let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; + let mut os = ObjectStyle::default(); + if let Some(it) = &src { + os.fill = Some(it.fill.clone()); + os.stroke = Some(it.stroke.clone()); + os.text_frame = it.text_frame().map(|t| t.options.clone()); + os.effects = Some(it.effects.clone()); + } + if let Some(sw) = str_param(p, "fill") { + os.fill = Some(designcraft_doc::Fill::swatch(sw)); + } + if let Some(ps) = str_param(p, "paragraphStyle") { + os.paragraph_style = Some(ps.to_string()); + } + if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? { + os.effects = Some(e); + } + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); + os.name = name.clone(); + d.styles_mut().object.push(os.clone()); + Ok(json!({"name": name})) + }) +} + +pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "style.object.edit"; + let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string(); + if name == designcraft_doc::NO_OBJECT_STYLE { + return Err(bad(CMD, format!("`{name}` can't be edited"))); + } + let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; + let effects = if p.get("effects").is_some_and(Value::is_null) { + if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { + return Err(bad(CMD, "`effects: null` can't be combined with effect settings")); + } + None + } else { + match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { + Some(e) => Some(e), + None => return Ok(json!({"name": name, "updated": 0})), + } + }; + if effects == old.effects { + return Ok(json!({"name": name, "updated": 0})); + } + s.edit(|d, _| { + let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { + return Err(bad(CMD, format!("no object style `{name}`"))); + }; + os.effects = effects.clone(); + // An item follows the style while its effects are the style's. When the style didn't set + // effects before, only items without effects of their own follow (the default styles are + // on most frames). + let follows = old.effects.clone().unwrap_or_default(); + let updated = match &effects { + Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()), + None => 0, + }; + Ok(json!({"name": name, "updated": updated})) + }) +} diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs new file mode 100644 index 0000000000..f39e89e78d --- /dev/null +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -0,0 +1,1236 @@ +use super::*; + +#[cfg(test)] +mod redefine_tests { + use super::*; + + fn sample() -> (Session, Value, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"First header"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect":[36,240,300,400],"content":"text","text":"Second header"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name":"Header","chars":{"size":18,"fill":"[Paper]"}})).unwrap(); + for story in [&a["story"], &b["story"]] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name":"Header"})).unwrap(); + } + s.execute("text.select", &json!({"story":a["story"],"anchor":0,"focus":5})).unwrap(); + (s, a["story"].clone(), b["story"].clone()) + } + + #[test] + fn redefine_paragraph_updates_other_stories_and_clears_the_source_overrides() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":20,"fill":"[Black]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter":9})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.redefine", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert!(s.doc().unwrap().doc.styles.para("Header").unwrap().chars.underline.is_none()); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(attrs["chars"]["size"], 20.0); + assert_eq!(attrs["chars"]["fill"], "[Black]"); + assert_eq!(attrs["para"]["spaceAfter"], 9.0); + assert_eq!(attrs["chars"]["underline"], story == &a); + } + s.execute("style.paragraph.edit", &json!({"name":"Header","chars":{"size":22}})).unwrap(); + s.execute("text.select", &json!({"story":a,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 22.0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } + + #[test] + fn redefine_character_does_not_capture_paragraph_font_size() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Underline","chars":{"underline":true}})).unwrap(); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Underline"})).unwrap(); + } + s.execute("type.char", &json!({"fill":"[Black]"})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Underline"})).unwrap(); + let style = s.doc().unwrap().doc.styles.char_style("Underline").unwrap(); + assert!(style.chars.size.is_none()); + assert_eq!(style.chars.underline, Some(true)); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + } + } + + #[test] + fn redefine_rejects_the_wrong_style_and_the_none_entry_atomically() { + let (mut s, _, _) = sample(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("style.paragraph.redefine", &json!({"name":"Other"})).is_err()); + assert!(s.execute("style.character.redefine", &json!({})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn redefine_character_at_a_caret_cleans_the_source_run_but_keeps_other_overrides() { + let (mut s, story, _) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + for (start, end, size) in [(0, 5, 24), (6, 12, 32)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":size})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + s.execute("text.select", &json!({"story":story,"anchor":2,"focus":2})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name":"Emphasis","chars":{"size":30}})).unwrap(); + for (start, end, size) in [(0, 5, 30.0), (6, 12, 32.0)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], size); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } +} + +#[cfg(test)] +mod application_inheritance_tests { + use super::*; + + fn setup(text: &str) -> (Session, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36,36,300,300], "content": "text", "text": text})).unwrap(); + (s, f["story"].clone()) + } + fn select(s: &mut Session, story: &Value, start: usize, end: usize) { + s.execute("text.select", &json!({"story": story, "anchor": start, "focus": end})).unwrap(); + } + fn attrs(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap() + } + + #[test] + fn paragraph_capture_source_follows_later_style_edits_and_undo() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 18, "fill": "[Paper]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 6})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Header", "fromSelection": true})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + select(&mut s, &story, 6, 6); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Header", "chars": {"size": 20, "fill": "[Black]"}, "para": {"spaceAfter": 12}})).unwrap(); + for pos in [0, 6] { + select(&mut s, &story, pos, pos + 1); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 20.0); + assert_eq!(a["chars"]["fill"], "[Black]"); + assert_eq!(a["para"]["spaceAfter"], 12.0); + } + } + + #[test] + fn applying_styles_keeps_different_overrides_and_named_character_styles() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 14})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 9})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 10}, "para": {"spaceAfter": 6}})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Body"})).unwrap(); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 14.0); + assert_eq!(a["para"]["spaceAfter"], 9.0); + assert_eq!(a["characterStyle"], "Emphasis"); + assert_eq!(a["chars"]["underline"], true); + } + + #[test] + fn character_capture_source_remains_sparse_and_follows_style_edits() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "fromSelection": true})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"underline": false}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["underline"], false); + assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").unwrap().chars.size.is_none()); + s.execute("style.paragraph.edit", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "chars": {"size": 20}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["size"], 20.0); + } + + #[test] + fn paragraph_selection_excludes_the_next_paragraph_at_its_start() { + let (mut s, story) = setup("é\nNext"); + select(&mut s, &story, 0, 3); + s.execute("style.paragraph.create", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Header", "clearOverrides": true})).unwrap(); + select(&mut s, &story, 3, 4); + assert_eq!(attrs(&mut s)["paragraphStyle"], designcraft_doc::BASIC_PARAGRAPH); + select(&mut s, &story, 0, 2); + assert_eq!(attrs(&mut s)["paragraphStyle"], "Header"); + } + + #[test] + fn clearing_a_partial_selection_preserves_character_overrides_outside_it() { + let (mut s, story) = setup("First Second"); + select(&mut s, &story, 0, 12); + s.execute("type.char", &json!({"size": 18})).unwrap(); + select(&mut s, &story, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "clearOverrides": true})).unwrap(); + assert_ne!(attrs(&mut s)["chars"]["size"], 18.0); + select(&mut s, &story, 6, 12); + assert_eq!(attrs(&mut s)["chars"]["size"], 18.0); + } +} + +/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute +/// is that command's parameter error, naming the group. +fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { + let mut a = T::default(); + match p.get(group) { + None | Some(Value::Null) => {} + Some(Value::Object(o)) => { + for (k, v) in o { + set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?; + } + } + Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))), + } + Ok(a) +} + +pub(super) fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { + let command = if paragraph { "style.paragraph.redefine" } else { "style.character.redefine" }; + let targets = crate::cmd::text::format_targets_pub(s); + let target = targets.first().ok_or_else(|| bad(command, "select text using the style to redefine"))?; + let state = s.doc()?; + let story = state.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(command, "no paragraph at the selection"))?; + let cf = crate::cmd::text::effective_char_format(s, target)?; + let selected = if paragraph { &pf.style } else { &cf.style }; + let name = str_param(p, "name").unwrap_or(selected).to_string(); + if name != *selected || name == if paragraph { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { + return Err(bad(command, "select text using the named style; the no-style entry cannot be redefined")); + } + let (base_para, base_chars) = state.doc.styles.resolve_para_style(&pf.style); + let (resolved_para, paragraph_chars) = state.doc.styles.resolve_para(pf); + let para_changes = base_para.delta(&resolved_para); + let mut current = if paragraph { + paragraph_chars.clone() + } else { + state.doc.styles.resolve_char(¶graph_chars, &designcraft_doc::CharFormat { style: name.clone(), over: CharAttrs::default() }) + }; + let inherited = if paragraph { base_chars } else { current.clone() }; + // A named character style is an intentional layer, not a paragraph override to absorb. + current.apply(&cf.over); + let char_changes = inherited.delta(¤t); + let result = s.edit(|d, _| { + if paragraph { + let style = d.styles_mut().para_mut(&name).ok_or_else(|| bad(command, "paragraph style no longer exists"))?; + style.para.merge(¶_changes); + style.chars.merge(&char_changes); + } else { + let style = + d.styles_mut().character.iter_mut().find(|st| st.name == name).ok_or_else(|| bad(command, "character style no longer exists"))?; + style.chars.merge(&char_changes); + } + let styles = d.styles.clone(); + for target in &targets { + let Some(story) = d.text_story_mut(target.story, target.cell) else { continue }; + // At a character-style caret, the sampled format belongs to the containing run + // (the preceding run at a boundary). Normalize that source run too, otherwise its + // old local values would mask the next edit of the redefined style. + let mut selected_range = target.range.clone(); + if !paragraph && selected_range.is_empty() { + let caret = selected_range.start; + let mut start = 0usize; + for run in &story.chars { + let end = start.saturating_add(run.len).min(story.len()); + if caret < end || (caret == end && caret > start) { + selected_range = start..end; + break; + } + start = end; + } + } + let end = if selected_range.is_empty() { selected_range.start } else { selected_range.end.saturating_sub(1) }; + let (first, last) = (story.para_at(selected_range.start), story.para_at(end)); + for (index, range) in story.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = story.paras.get_mut(index) else { continue }; + if paragraph && pf.style != name { + continue; + } + if paragraph { + let (pp, cp) = styles.resolve_para(pf); + let (base_p, base_c) = styles.resolve_para_style(&name); + pf.para = base_p.delta(&pp); + pf.chars = base_c.delta(&cp); + } + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(story.len()); + let range = if paragraph { range.start..end } else { range.start.max(selected_range.start)..end.min(selected_range.end) }; + story.format_chars(range, |format| { + if !paragraph && format.style != name { + return; + } + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&format.style)); + let resolved = styles.resolve_char(&base, format); + format.over = inherited.delta(&resolved); + }); + } + } + Ok(json!({"name":name})) + }); + if result.is_ok() { + s.typing_format = None; + } + result +} + +/// Apply paragraph style `name` over `r` in one story; `clear` drops overrides instead of keeping +/// only those that differ from the style (`resolved` is the style's paragraph and character attributes). +fn apply_para_in_story( + st: &mut designcraft_doc::Story, + r: &std::ops::Range, + name: &str, + clear: bool, + resolved: (&designcraft_doc::ParaProps, &designcraft_doc::CharProps), + styles: &designcraft_doc::Styles, +) { + let (style_para, style_chars) = resolved; + // Text selections are half-open: a selection ending at the next paragraph's + // start must not apply a style to that paragraph too. + let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; + let (first, last) = (st.para_at(r.start), st.para_at(end)); + st.format_paras(r.start..end, |f| { + f.style = name.to_string(); + if clear { + f.para = ParaAttrs::default(); + f.chars = CharAttrs::default(); + } else { + f.para.retain_differences(style_para); + f.chars.retain_differences(style_chars); + } + }); + for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(st.len()); + let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; + st.format_chars(range, |f| { + if clear { + f.over = CharAttrs::default(); + } else { + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&f.style)); + f.over.retain_differences(&inherited); + } + }); + } +} + +pub(super) fn apply_para(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); + let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); + if s.doc()?.doc.styles.para(&name).is_none() { + return Err(bad("style.paragraph.apply", format!("no paragraph style `{name}`"))); + } + let targets = crate::cmd::text::format_targets_pub(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + let (style_para, style_chars) = styles.resolve_para_style(&name); + for t in &targets { + if let Some(st) = d.text_story_mut(t.story, t.cell) { + apply_para_in_story(st, &t.range, &name, clear, (&style_para, &style_chars), &styles); + } + } + ok() + }) +} + +pub(super) fn apply_char(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); + if s.doc()?.doc.styles.char_style(&name).is_none() { + return Err(bad("style.character.apply", format!("no character style `{name}`"))); + } + let targets = crate::cmd::text::format_targets_pub(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + if let Some(st) = d.text_story_mut(t.story, t.cell) { + for (pi, range) in st.para_ranges().into_iter().enumerate() { + let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(&name)); + st.format_chars(range, |f| { + f.style = name.clone(); + f.over.retain_differences(&inherited); + }); + } + } + } + ok() + }) +} + +/// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit +/// "continue" so it also overrides a Start At the style would inherit. +fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { + let explicit = + p.and_then(Value::as_object).is_some_and(|o| o.iter().any(|(k, v)| v.is_null() && k.replace('_', "").eq_ignore_ascii_case("startAt"))); + if explicit { + para.start_at = Some(None); + } +} + +pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); + let mut para: ParaAttrs = + attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; + continue_numbering(p.get("para"), &mut para); + explicit_default_kinsoku(&mut para, p.get("para")); + check_grep_styles("style.paragraph.create", ¶)?; + let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) + && !cur.is_null() + { + if let Ok(pp) = serde_json::from_value::(cur["para"].clone()) { + let mut full = ParaProps_to_attrs(&pp); + full.merge(¶); + para = full; + } + if let Ok(cp) = serde_json::from_value::(cur["chars"].clone()) { + let mut full = CharProps_to_attrs(&cp); + full.merge(&chars); + chars = full; + } + } + let based_on = str_param(p, "basedOn").map(str::to_string); + let next = str_param(p, "nextStyle").map(str::to_string); + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); + if let Some(b) = &based_on + && d.styles.para(b).is_none() + { + return Err(bad("style.paragraph.create", format!("no style `{b}`"))); + } + d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); + Ok(json!({"name": name})) + }) +} + +/// `kinsoku: null` sets the composer's default kinsoku rules, also over a Based On style's set. +/// For the other attributes `null` leaves the style's value as it is. +fn explicit_default_kinsoku(para: &mut ParaAttrs, given: Option<&Value>) { + if given.and_then(|o| o.get("kinsoku")).is_some_and(Value::is_null) { + para.kinsoku = Some(None); + } +} + +/// The compile error of the first GREP style in `a` whose pattern can't compile. +pub(crate) fn grep_style_error(a: &ParaAttrs) -> Option { + a.grep_styles + .iter() + .flatten() + .filter(|g| !g.pattern.is_empty()) + .find_map(|g| designcraft_compose::grep::Grep::new(&g.pattern, Default::default()).err().map(|e| format!("{e} (pattern {:?})", g.pattern))) +} + +/// Rejects GREP styles a command would set with a pattern that can't compile. +pub(crate) fn check_grep_styles(cmd: &str, a: &ParaAttrs) -> Result<()> { + grep_style_error(a).map_or(Ok(()), |e| Err(bad(cmd, e))) +} + +/// One warning for each paragraph style or paragraph whose GREP style pattern can't compile. +/// Such a GREP style stays in the document and composition skips it. +pub(crate) fn grep_style_warnings(d: &designcraft_doc::Document) -> Vec { + let mut out: Vec = d + .styles + .paragraph + .iter() + .filter_map(|st| grep_style_error(&st.para).map(|e| format!("paragraph style {:?}: GREP style not applied: {e}", st.name))) + .collect(); + for st in d.stories.values() { + for (i, pf) in st.paras.iter().enumerate() { + if let Some(e) = grep_style_error(&pf.para) { + out.push(format!("story {} paragraph {}: GREP style not applied: {e}", st.id.0, i + 1)); + } + } + } + out +} + +#[allow(non_snake_case)] +fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs { + designcraft_doc::ParaProps::common([p]) +} + +#[allow(non_snake_case)] +fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { + designcraft_doc::CharProps::common([p]) +} + +pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); + let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); + let based = p.get("basedOn").cloned(); + s.edit(|d, _| { + if let Some(Value::String(b)) = &based + && d.styles.para_based_on_cycles(&name, b) + { + return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + } + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.paragraph.edit", format!("can't rename to `{n}`"))); + } + if d.styles.para(n).is_some() { + return Err(bad("style.paragraph.edit", format!("a paragraph style named `{n}` already exists"))); + } + } + // A rule object changes only the rule fields it names, over the style's resolved rule. + let (current, _) = d.styles.resolve_para_style(&name); + let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; + continue_numbering(p.get("para"), &mut para); + explicit_default_kinsoku(&mut para, p.get("para")); + check_grep_styles("style.paragraph.edit", ¶)?; + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + st.para.merge(¶); + st.chars.merge(&chars); + match based { + Some(Value::String(b)) => st.based_on = Some(b), + Some(Value::Null) => st.based_on = None, + _ => {} + } + if let Some(n) = rename.clone() { + // Every use: other styles, stories, table cells, footnotes, object styles. + rename_style(d, true, &name, &n); + } + ok() + }) +} + +/// Rename a paragraph or character style everywhere it's used; its Export Tagging follows. +fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let st = d.styles_mut(); + if para { + for s in &mut st.paragraph { + if s.name == from { + s.name = to.to_string(); + } + } + } else { + for s in &mut st.character { + if s.name == from { + s.name = to.to_string(); + } + } + } + // An entry already under `to` belongs to no style (names are unique), so it never survives. + st.export_tags.remove(&export_tag_key(para, to)); + if let Some(tag) = st.export_tags.remove(&export_tag_key(para, from)) { + st.export_tags.insert(export_tag_key(para, to), tag); + } + redirect_style_refs(d, para, from, to); +} + +/// The `Styles::export_tags` key of a paragraph (`para`) or character style. +pub(super) fn export_tag_key(para: bool, name: &str) -> String { + format!("{}:{name}", if para { "p" } else { "c" }) +} + +/// Point every reference to style `from` at `to`, except the style's own registry entry (its +/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which +/// instead removes the entry, after its former uses point at the replacement). +fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let rename = |r: &mut String| { + if r == from { + *r = to.to_string(); + } + }; + let st = d.styles_mut(); + // A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent. + let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned(); + if para { + let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); + for s in &mut st.paragraph { + if s.based_on.as_deref() == Some(from) { + s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; + } + if s.next_style.as_deref() == Some(from) { + s.next_style = Some(to.to_string()); + } + } + let objects = st.object.iter_mut().map(|o| &mut o.paragraph_style); + for r in objects.chain(st.cell.iter_mut().map(|c| &mut c.paragraph_style)) { + if r.as_deref() == Some(from) { + *r = Some(to.to_string()); + } + } + rename(&mut st.default_paragraph); + let (fo, eo) = (&mut d.footnote_options, &mut d.endnote_options); + [&mut fo.para_style, &mut eo.para_style, &mut eo.heading_style].into_iter().for_each(rename); + d.toc.iter_mut().flat_map(|t| t.entries.iter_mut().map(|e| &mut e.style)).for_each(rename); + for t in d.toc.iter_mut() { + let formats = t.entries.iter_mut().map(|e| &mut e.entry_style).chain([&mut t.title_style]); + for r in formats.filter(|r| r.as_deref() == Some(from)) { + *r = Some(to.to_string()); + } + } + if let designcraft_doc::EpubSplit::Style(n) = &mut d.settings.epub_split { + rename(n); + } + } else { + let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); + for s in &mut st.character { + if s.based_on.as_deref() == Some(from) { + s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; + } + } + for s in &mut st.paragraph { + rename_char_refs(&mut s.para, from, to); + } + rename(&mut st.default_character); + rename(&mut d.footnote_options.ref_char_style); + } + // The table of contents' character styles ([None] is stored as no style). + if !para && let Some(toc) = d.toc.as_mut() { + for e in &mut toc.entries { + for n in [&mut e.page_number_style, &mut e.separator_style] { + if n.as_deref() == Some(from) { + *n = Some(to.to_string()).filter(|t| t != designcraft_doc::NO_CHAR_STYLE); + } + } + } + } + for sid in d.stories.keys().copied().collect::>() { + let Some(story) = d.story_mut(sid) else { continue }; + story.for_each_text_mut(&mut |st| { + if para { + for f in st.paras.iter_mut().filter(|f| f.style == from) { + f.style = to.to_string(); + } + } else { + for r in st.chars.iter_mut().filter(|r| r.format.style == from) { + r.format.style = to.to_string(); + } + for f in &mut st.paras { + rename_char_refs(&mut f.para, from, to); + } + } + st.rev = st.rev.wrapping_add(1); + }); + } +} + +/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph +/// attributes at a renamed (or deleted, pointed at its replacement) character style. +fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { + let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); + let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); + let names = names.chain(p.drop_cap_style.iter_mut()); + for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) { + if n == from { + *n = to.to_string(); + } + } +} + +pub(super) fn group_styles(s: &mut Session, p: &Value) -> Result { + let para = match str_param(p, "kind").unwrap_or("paragraph") { + "paragraph" => true, + "character" => false, + k => return Err(bad("style.group", format!("unknown kind `{k}`"))), + }; + let names: Vec = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let group = str_param(p, "group").unwrap_or("").trim_matches('/').to_string(); + s.edit(|d, _| { + let mut renamed = serde_json::Map::new(); + for n in &names { + if n.starts_with('[') { + return Err(bad("style.group", format!("`{n}` is built in"))); + } + let exists = if para { d.styles.para(n).is_some() } else { d.styles.character.iter().any(|c| c.name == *n) }; + if !exists { + return Err(bad("style.group", format!("no style `{n}`"))); + } + let base = n.rsplit('/').next().unwrap_or(n); + let mut to = if group.is_empty() { base.to_string() } else { format!("{group}/{base}") }; + let taken = |d: &designcraft_doc::Document, x: &str| { + if para { d.styles.para(x).is_some() } else { d.styles.character.iter().any(|c| c.name == x) } + }; + if to != *n { + to = Styles::unique_name(|x| taken(d, x), &to); + rename_style(d, para, n, &to); + renamed.insert(n.clone(), json!(to)); + } + } + Ok(json!({"renamed": renamed})) + }) +} + +pub(super) fn delete_para(s: &mut Session, p: &Value) -> Result { + delete_style(s, p, true) +} + +pub(super) fn delete_char(s: &mut Session, p: &Value) -> Result { + delete_style(s, p, false) +} + +/// Delete a paragraph (`para`) or character style; everything that used it uses `replaceWith` +/// (default [Basic Paragraph] or [None]) instead, in one undo step. +fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { + let (id, default) = + if para { ("style.paragraph.delete", designcraft_doc::BASIC_PARAGRAPH) } else { ("style.character.delete", designcraft_doc::NO_CHAR_STYLE) }; + let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad(id, "missing name"))?.to_string(); + let repl = str_param(p, "replaceWith").unwrap_or(default).to_string(); + if name.starts_with('[') { + return Err(bad(id, "built-in styles can't be deleted")); + } + if repl == name { + return Err(bad(id, "`replaceWith` can't be the deleted style")); + } + s.edit(|d, _| { + let st = &d.styles; + let exists = |n: &str| if para { st.para(n).is_some() } else { n == designcraft_doc::NO_CHAR_STYLE || st.char_style(n).is_some() }; + if !exists(&name) { + return Err(bad(id, format!("no style `{name}`"))); + } + if !exists(&repl) { + return Err(bad(id, format!("no style `{repl}` to replace it with"))); + } + // The deleted style's children are based on the replacement, or on the deleted style's + // own parent when the replacement descends from it (so no based-on chain loops). + let (own_parent, descends) = if para { + (st.para(&name).and_then(|x| x.based_on.clone()), st.para_based_on_cycles(&name, &repl)) + } else { + (st.char_style(&name).and_then(|x| x.based_on.clone()), st.char_based_on_cycles(&name, &repl)) + }; + let parent = if descends { + own_parent.filter(|b| *b != name) + } else if repl == designcraft_doc::NO_CHAR_STYLE || repl == designcraft_doc::NO_PARA_STYLE { + None + } else { + Some(repl.clone()) + }; + let st = d.styles_mut(); + let based = if para { + st.paragraph.retain(|x| x.name != name); + st.paragraph.iter_mut().map(|x| &mut x.based_on).collect::>() + } else { + st.character.retain(|x| x.name != name); + st.character.iter_mut().map(|x| &mut x.based_on).collect() + }; + for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { + *b = parent.clone(); + } + // The replacement keeps its own Export Tagging. + st.export_tags.remove(&export_tag_key(para, &name)); + // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. + redirect_style_refs(d, para, &name, &repl); + ok() + }) +} + +pub(super) fn create_char(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); + let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Some(target) = crate::cmd::text::format_targets_pub(s).first() + { + let st = s.doc()?; + // Match paragraph fromSelection's first-character/caret policy, but keep paragraph + // attributes inherited instead of freezing the fully resolved character properties. + let format = crate::cmd::text::effective_char_format(s, target)?; + let mut captured = st.doc.styles.char_style_attrs(&format.style); + captured.merge(&format.over); + // Replay the explicit fields so null clears a captured value rather than looking absent. + if let Some(overrides) = p.get("chars").and_then(Value::as_object) { + for (key, value) in overrides { + captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?; + } + } + chars = captured; + } + // Based on [None] is based on nothing. + let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); + s.edit(|d, _| { + if let Some(b) = &based_on + && d.styles.char_style(b).is_none() + { + return Err(bad("style.character.create", format!("no character style `{b}`"))); + } + let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base); + d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() }); + Ok(json!({"name": name})) + }) +} + +pub(super) fn edit_char(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string(); + if name == designcraft_doc::NO_CHAR_STYLE { + return Err(bad("style.character.edit", "[None] can't be edited")); + } + let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; + // `null` unsets an attribute (the style no longer sets it). + let unset: Vec = p + .get("chars") + .and_then(Value::as_object) + .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); + let based: Option> = match p.get("basedOn") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None), + Some(Value::String(b)) => Some(Some(b.clone())), + Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")), + }; + s.edit(|d, _| { + if let Some(Some(b)) = &based { + if d.styles.char_style(b).is_none() { + return Err(bad("style.character.edit", format!("no character style `{b}`"))); + } + if d.styles.char_based_on_cycles(&name, b) { + return Err(bad("style.character.edit", "based-on would create a cycle")); + } + } + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + } + if d.styles.char_style(n).is_some() { + return Err(bad("style.character.edit", format!("a character style named `{n}` already exists"))); + } + } + let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; + st.chars.merge(&chars); + for k in &unset { + st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?; + } + if let Some(b) = based.clone() { + st.based_on = b; + } + if let Some(n) = &rename { + // Every use: other styles, stories, table cells, nested and GREP styles, footnotes. + rename_style(d, false, &name, n); + } + ok() + }) +} + +/// #777: the params descriptions name the attributes, and a wrong one is the command's error. +#[cfg(test)] +mod attribute_error_tests { + use super::*; + use crate::cmd::find_command; + + #[test] + fn a_wrong_attribute_names_the_command_and_the_right_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string(); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})), + "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?" + ); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})), + "invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?" + ); + assert!( + err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.create`: chars:") + ); + assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes")); + s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap(); + assert!( + err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.paragraph.edit`: chars:") + ); + s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert!( + err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.edit`: chars:") + ); + for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + let doc = find_command(id).unwrap().params; + assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}"); + } + assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align")); + } +} + +#[cfg(test)] +mod export_tag_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn export_tagging_shapes_epub_markup() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter Title", "chars": {"size": 24}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap(); + assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err()); + // Split Document is for paragraph styles; it alone keeps the tagging. + assert!(s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "split": true})).is_err()); + s.execute("style.paragraph.create", &json!({"name": "Part"})).unwrap(); + assert_eq!(s.execute("style.exportTag", &json!({"style": "Part", "split": true})).unwrap()["split"], true); + assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_some_and(|t| t.split)); + s.execute("style.exportTag", &json!({"style": "Part", "split": false})).unwrap(); + assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_none(), "an untouched tagging goes"); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Chapter Title"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 10, "focus": 14})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Key Term"})).unwrap(); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("

Origins

"), "{html}"); + assert!(html.contains("word"), "{html}"); + // Tagged PDF: a heading element and a paragraph element. + let r = s.execute("file.exportPdf", &json!({"tagged": true, "compressImages": false})).unwrap(); + let pdf = crate::cmd::file::base64_decode(r["base64"].as_str().unwrap()); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); + assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); + } + + #[test] + fn export_tagging_follows_a_renamed_style_and_goes_with_a_deleted_one() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Term"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Head", "tag": "h2", "class": "head"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Body", "tag": "p", "class": "body"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Term", "character": true, "tag": "em", "class": "term"})).unwrap(); + let tag = |s: &Session, name: &str, character: bool| { + s.doc().unwrap().doc.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone())) + }; + let pair = |t: &str, c: &str| Some((t.to_string(), c.to_string())); + // A rename onto another style's name is refused, so neither tagging is lost. + assert!(s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Body"})).is_err()); + assert_eq!((tag(&s, "Head", false), tag(&s, "Body", false)), (pair("h2", "head"), pair("p", "body"))); + + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Title"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Term", "rename": "Word"})).unwrap(); + assert_eq!(tag(&s, "Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Word", true), pair("em", "term")); + assert_eq!(tag(&s, "Term", true), None); + // Moving a style into a group renames it to Group/Name. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Title"], "group": "Heads"})).unwrap(); + assert_eq!(tag(&s, "Heads/Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(tag(&s, "Head", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + assert_eq!(tag(&s, "Term", true), pair("em", "term")); + assert_eq!(tag(&s, "Word", true), None); + + // Deleting drops the deleted style's tagging; the replacement keeps its own. + s.execute("style.paragraph.delete", &json!({"name": "Head", "replaceWith": "Body"})).unwrap(); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Body", false), pair("p", "body")); + s.execute("style.character.delete", &json!({"name": "Term"})).unwrap(); + assert_eq!(tag(&s, "Term", true), None); + assert!(s.doc().unwrap().doc.styles.export_tags.keys().all(|k| k == "p:Body")); + } + + #[test] + fn a_rename_onto_a_taken_name_says_so_and_a_trimmed_rename_drops_a_stale_tag() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (kind, a, b) in [("paragraph", "Head", "Body"), ("character", "Term", "Word")] { + s.execute(&format!("style.{kind}.create"), &json!({"name": a})).unwrap(); + s.execute(&format!("style.{kind}.create"), &json!({"name": b})).unwrap(); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": b})).unwrap_err().to_string(); + assert!(e.contains(&format!("a {kind} style named `{b}` already exists")), "{e}"); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": "[None]"})).unwrap_err().to_string(); + assert!(e.contains("can't rename to `[None]`"), "{e}"); + } + // An entry left under a name no style has doesn't attach to a style renamed onto it. + s.edit(|d, _| { + d.styles_mut() + .export_tags + .insert("p:Title".into(), designcraft_doc::ExportTag { tag: "h6".into(), class: "stale".into(), ..Default::default() }); + Ok(()) + }) + .unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": " Title "})).unwrap(); + let st = &s.doc().unwrap().doc.styles; + assert!(st.para("Title").is_some(), "the new name is trimmed"); + assert_eq!(st.export_tag("Title", false), None); + } +} + +#[cfg(test)] +mod cjk_tests { + use super::*; + #[test] + fn cjk_composite_font_command_invalidates_layout_and_undo_restores_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 150], "content": "text", "text": "AB"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let definition = |scale| json!({"name": "Mixed", "entries": [{"family": "Source Serif 4", "relativeSize": scale}]}); + s.execute("style.compositeFont.set", &definition(1.0)).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 2})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Mixed", "leadingAki": 0.25, "jidori": 4})).unwrap(); + let old = s.cache.get(&s.doc().unwrap().doc, sid, None); + s.execute("style.compositeFont.set", &definition(2.0)).unwrap(); + let new = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert!(!std::sync::Arc::ptr_eq(&old, &new)); + assert!(new.frames[0].lines[0].end_x > old.frames[0].lines[0].end_x); + s.execute("edit.undo", &json!({})).unwrap(); + let restored = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert_eq!(old.frames[0].lines[0].end_x, restored.frames[0].lines[0].end_x); + assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); + } +} + +#[cfg(test)] +mod delete_tests { + use designcraft_doc::{CellStyle, ObjectStyle, StoryId, Toc, TocEntry}; + use serde_json::json; + + use crate::Session; + + /// Is `name` still named anywhere in the document? + fn named(s: &Session, name: &str) -> bool { + serde_json::to_string(&*s.doc().unwrap().doc).unwrap().contains(&format!("\"{name}\"")) + } + + fn char_doc() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"tracking": 20}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Child", "basedOn": "Emphasis"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Strong", "chars": {"size": 14}})).unwrap(); + let refs = json!({ + "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], + "nestedLineStyles": [{"style": "Emphasis", "lines": 1}], + "dropCapLines": 2, "dropCapChars": 1, "dropCapStyle": "Emphasis"}); + s.execute("style.paragraph.create", &json!({"name": "Lead", "para": refs})).unwrap(); + s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); + let sid = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap()["story"] + .as_u64() + .unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + // Local paragraph overrides name it too. + s.execute("type.para", &json!({"attrs": refs})).unwrap(); + s.edit(|d, _| { + d.styles_mut().default_character = "Emphasis".into(); + Ok(()) + }) + .unwrap(); + assert!(named(&s, "Emphasis")); + (s, sid) + } + + #[test] + fn deleting_a_character_style_points_every_use_at_the_replacement() { + let (mut s, sid) = char_doc(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Strong"})).unwrap(); + assert!(!named(&s, "Emphasis"), "no dangling reference"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Strong")); + assert_eq!(d.story(StoryId(sid)).unwrap().chars[0].format.style, "Strong"); + let lead = &d.styles.para("Lead").unwrap().para; + assert_eq!(lead.nested_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.grep_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.drop_cap_style.as_deref(), Some("Strong")); + assert_eq!(d.footnote_options.ref_char_style, "Strong"); + assert_eq!(d.styles.default_character, "Strong"); + // One undo step brings the style and every use back. + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").is_some()); + assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().chars[0].format.style, "Emphasis"); + } + + #[test] + fn deleting_a_character_style_for_none_unsets_based_on() { + let (mut s, _) = char_doc(); + s.execute("style.character.delete", &json!({"name": "Emphasis"})).unwrap(); + assert!(!named(&s, "Emphasis"), "no dangling reference"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.styles.char_style("Child").unwrap().based_on, None); + assert_eq!(d.footnote_options.ref_char_style, designcraft_doc::NO_CHAR_STYLE); + } + + /// Replacing a style with its own descendant rebases the deleted style's children on its + /// parent, so no based-on chain becomes a cycle. + #[test] + fn replacing_a_style_with_its_child_makes_no_cycle() { + let (mut s, _) = char_doc(); + s.execute("style.character.create", &json!({"name": "Grandchild", "basedOn": "Child"})).unwrap(); + s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Grandchild"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Base")); + assert_eq!(d.styles.char_style("Grandchild").unwrap().based_on.as_deref(), Some("Child")); + assert!(!named(&s, "Emphasis")); + } + + #[test] + fn bad_style_deletes_are_errors_and_change_nothing() { + let (mut s, _) = char_doc(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for (id, p) in [ + ("style.character.delete", json!({})), + ("style.character.delete", json!({"name": ""})), + ("style.character.delete", json!({"name": "Nope"})), + ("style.character.delete", json!({"name": "[None]"})), + ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Nope"})), + ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Emphasis"})), + ("style.paragraph.delete", json!({})), + ("style.paragraph.delete", json!({"name": ""})), + ("style.paragraph.delete", json!({"name": "Nope"})), + ("style.paragraph.delete", json!({"name": "[Basic Paragraph]"})), + ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Nope"})), + ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Head"})), + ] { + assert!(s.execute(id, &p).is_err(), "{id} {p}"); + } + assert_eq!(*s.doc().unwrap().doc, *before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Table Body", "nextStyle": "Table Body"})).unwrap(); + s.edit(|d, _| { + let story = *d.stories.keys().next().ok_or(crate::EngineError::NoDocument)?; + let st = d.styles_mut(); + st.default_paragraph = "Table Body".into(); + st.object.push(ObjectStyle { name: "Boxed".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); + st.cell.push(CellStyle { name: "Body Cell".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); + d.footnote_options.para_style = "Table Body".into(); + d.endnote_options.para_style = "Table Body".into(); + d.endnote_options.heading_style = "Table Body".into(); + d.settings.epub_split = designcraft_doc::EpubSplit::Style("Table Body".into()); + d.toc = Some(Toc { + story, + title: "Contents".into(), + entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], + page_numbers: false, + title_style: Some("Table Body".into()), + }); + Ok(()) + }) + .unwrap(); + assert!(named(&s, "Table Body")); + s.execute("style.paragraph.delete", &json!({"name": "Table Body", "replaceWith": "Body"})).unwrap(); + assert!(!named(&s, "Table Body"), "no dangling reference"); + let d = s.doc().unwrap().doc.clone(); + let sub = d.styles.para("Sub").unwrap(); + assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body"))); + assert_eq!(d.styles.default_paragraph, "Body"); + assert_eq!(d.endnote_options.heading_style, "Body"); + assert_eq!(d.settings.epub_split, designcraft_doc::EpubSplit::Style("Body".into()), "EPUB Split Document follows the replacement"); + let after = s.execute("preflight.run", &json!({})).unwrap(); + assert!(!after.to_string().contains("Table Body"), "{after}"); + } +} + +#[cfg(test)] +mod grep_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn grep_style_pattern_that_cannot_compile_is_rejected() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); + let bad = json!([{"style": "Keep", "pattern": "(\\d+"}]); + let e = s.execute("style.paragraph.create", &json!({"name": "Broken", "para": {"grepStyles": bad}})).unwrap_err().to_string(); + assert!(e.contains("bad GREP") && e.contains("(\\\\d+"), "{e}"); + assert!(s.doc().unwrap().doc.styles.para("Broken").is_none()); + + // The lookbehind dialect compiles. + let good = json!([{"style": "Keep", "pattern": "(?<=\\s)[aiouwzAIOUWZ]\\s"}]); + s.execute("style.paragraph.create", &json!({"name": "Polish", "para": {"grepStyles": good}})).unwrap(); + let e = s.execute("style.paragraph.edit", &json!({"name": "Polish", "para": {"grepStyles": bad, "spaceAfter": 6}})).unwrap_err().to_string(); + assert!(e.contains("bad GREP"), "{e}"); + let st = s.doc().unwrap().doc.styles.para("Polish").unwrap().clone(); + assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "(?<=\\s)[aiouwzAIOUWZ]\\s"); + assert_eq!(st.para.space_after, None, "nothing changes"); + + // A paragraph override too. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ala i kot"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let e = s.execute("type.para", &json!({"grepStyles": bad})).unwrap_err().to_string(); + assert!(e.contains("bad GREP"), "{e}"); + } + + #[test] + fn idml_import_keeps_a_broken_grep_style_and_warns() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Broken"})).unwrap(); + // Written past the command, as another application could. + s.edit(|d, _| { + if let Some(st) = d.styles_mut().para_mut("Broken") { + st.para.grep_styles = Some(vec![designcraft_doc::GrepStyle { style: "Keep".into(), pattern: "[a-".into() }]); + } + Ok(()) + }) + .unwrap(); + let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let r = s.execute("file.openIdml", &json!({"base64": b64})).unwrap(); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("Broken") && warnings.contains("[a-") && warnings.contains("bad GREP"), "{warnings}"); + let st = s.doc().unwrap().doc.styles.para("Broken").unwrap().clone(); + assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "[a-", "the style opens with its pattern"); + } +} diff --git a/crates/engine/src/cmd/style/swatches.rs b/crates/engine/src/cmd/style/swatches.rs new file mode 100644 index 0000000000..74d7a2bdbb --- /dev/null +++ b/crates/engine/src/cmd/style/swatches.rs @@ -0,0 +1,1002 @@ +use super::color::check_text_nesting; +use super::*; + +pub(super) fn create_swatch(s: &mut Session, p: &Value) -> Result { + if let Some(g) = p.get("gradient") { + return create_gradient_swatch(s, p, g); + } + let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color (or gradient)"))?; + let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?; + let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| value_name(color)); + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { + color, + color_type: if spot { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }, + }, + locked: false, + named: true, + hidden: false, + }); + Ok(json!({"name": name})) + }) +} + +/// `swatch.create {gradient}`: a named gradient swatch. Stops take a colour swatch by name (as a +/// swatch dragged onto the Gradient panel's stop) or a colour. +fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result { + use designcraft_color::{Gradient, GradientKind, GradientStop}; + const ID: &str = "swatch.create"; + let kind = match g.get("kind").and_then(Value::as_str) { + None | Some("linear") => GradientKind::Linear, + Some("radial") => GradientKind::Radial, + Some(k) => return Err(bad(ID, format!("unknown gradient kind {k}"))), + }; + let doc = &s.doc()?.doc; + let mut stops = Vec::new(); + for st in g.get("stops").and_then(Value::as_array).ok_or_else(|| bad(ID, "a gradient needs stops"))? { + // The stop keeps the swatch, so print export writes it in that swatch's inks. + let (color, swatch) = match (st.get("swatch").and_then(Value::as_str), st.get("color")) { + (Some(n), _) => match doc.swatch(n).map(|w| &w.value) { + Some(SwatchValue::Gradient { .. }) => return Err(bad(ID, format!("`{n}` is a gradient; a stop takes a colour"))), + Some(_) => (doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no color")))?, Some(n.to_string())), + None => return Err(bad(ID, format!("no swatch named `{n}`"))), + }, + (None, Some(c)) => (parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?, None), + (None, None) => return Err(bad(ID, "a stop needs a swatch or a color")), + }; + let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; + stops.push(GradientStop { + offset: pct("location", 0.0), + color, + opacity: pct("opacity", 100.0), + midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), + swatch, + }); + } + if stops.len() < 2 { + return Err(bad(ID, "a gradient needs at least two stops")); + } + let mut gradient = Gradient { kind, stops }; + gradient.sort(); + let name = str_param(p, "name").unwrap_or("New Gradient Swatch").to_string(); + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); + d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); + Ok(json!({"name": name})) + }) +} + +/// `swatch.new`: InDesign's New Swatch. An unnamed colour or gradient (the selection's, or +/// `swatch`) is added to the swatches under a name, and stays applied; a named swatch is +/// duplicated as " copy". +pub(super) fn new_swatch(s: &mut Session, p: &Value) -> Result { + const ID: &str = "swatch.new"; + let st = s.doc()?; + let name = match str_param(p, "swatch") { + Some(n) => n.to_string(), + None => { + let Some(id) = st.selection.items.first().copied() else { + // Nothing selected: a new colour swatch. + return create_swatch(s, &json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); + }; + let it = st.doc.item(id).ok_or_else(|| bad(ID, "no such item"))?; + if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } + } + }; + let w = st.doc.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch named `{name}`")))?; + if matches!(w.value, SwatchValue::None | SwatchValue::Registration) { + return Err(bad(ID, format!("`{name}` can't be made a new swatch"))); + } + if w.hidden { + // An unnamed gradient takes InDesign's "New Gradient Swatch"; an unnamed colour its values. + let new = match w.value { + SwatchValue::Gradient { .. } => Styles::unique_name(|n| st.doc.swatch(n).is_some(), "New Gradient Swatch"), + _ => name.clone(), + }; + return add_to_swatches(s, &json!({"swatch": name, "name": new})); + } + s.edit(|d, _| { + let copy = Styles::unique_name(|n| d.swatch(n).is_some(), &format!("{name} copy")); + d.swatches.push(Swatch { name: copy.clone(), locked: false, named: true, hidden: false, ..w.clone() }); + Ok(json!({"name": copy})) + }) +} + +pub fn parse_color(v: &Value) -> Option { + use designcraft_color::Color; + match v { + Value::String(s) => Color::from_hex(s), + // `{model, …}` is the colour's own form (as `color.convert` takes it): fractions, never guessed. + Value::Object(o) if o.contains_key("model") => model_color(v), + // One scale per colour, not per component: [1, 128, 255] is 0..255. Only an RGB array whose + // components are all within 0..1 is read as fractions (the older form). + Value::Array(a) if a.len() >= 3 => { + let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; + let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; + Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) + } + // Bare CMYK is always 0..100, as documented: C 1 % is a real tint, so values of 1 or less can't + // mean fractions (#910). + Value::Object(o) => { + if o.contains_key("gray") { + let gray = o.get("gray").and_then(Value::as_f64)?; + return Some(Color::gray(if gray > 1.0 { gray / 100.0 } else { gray } as f32)); + } + let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| (x / 100.0) as f32); + Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) + } + _ => None, + } +} + +/// `{model: rgb|cmyk|gray, …}` with every component in 0..=1; anything else is not a colour. +fn model_color(v: &Value) -> Option { + use designcraft_color::Color; + let color: Color = serde_json::from_value(v.clone()).ok()?; + // A float's last bit (100.0 / 100.0 ≠ 1.0 after an f32 round trip) is not out of range. + let unit = |x: f32| x.is_finite() && (-1e-4..=1.0 + 1e-4).contains(&x); + let q = |x: f32| x.clamp(0.0, 1.0); + match color { + Color::Rgb { r, g, b } => [r, g, b].into_iter().all(unit).then(|| Color::rgb(q(r), q(g), q(b))), + Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().all(unit).then(|| Color::cmyk(q(c), q(m), q(y), q(k))), + Color::Gray { k } => unit(k).then_some(Color::Gray { k: q(k) }), + } +} + +/// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…"). +pub(super) fn value_name(color: designcraft_color::Color) -> String { + designcraft_color::swatch::value_name(color) +} + +pub(super) fn load_swatches(s: &mut Session, p: &Value) -> Result { + let bytes = match (str_param(p, "path"), str_param(p, "base64")) { + (_, Some(b)) => crate::cmd::file::base64_decode(b), + (Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?, + _ => return Err(bad("swatch.load", "give `path` or `base64`")), + }; + let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + // The position of the first swatch with each name, kept up to date as the file's colors are added. + let mut at: std::collections::HashMap = std::collections::HashMap::new(); + for (i, x) in d.swatches.iter().enumerate() { + at.entry(x.name.clone()).or_insert(i); + } + let mut names = FileNames::default(); + let (mut added, mut replaced) = (0, 0); + for mut w in incoming { + // An unnamed color is named by its value, and a name the file already gave gets a number. + if w.name.is_empty() + && let SwatchValue::Color { color, .. } = w.value + { + w.name = value_name(color); + w.named = false; + } + w.name = names.unique(std::mem::take(&mut w.name)); + match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { + Some(x) if x.locked => {} + Some(x) => { + if replace { + x.value = w.value; + x.hidden = false; + replaced += 1; + } + } + None => { + at.insert(w.name.clone(), d.swatches.len()); + d.swatches.push(w); + added += 1; + } + } + } + Ok(json!({"added": added, "replaced": replaced})) + }) +} + +/// The swatch names one `.ase` file has given. A name already given gets the first free number +/// ("Red 2"), as [`Styles::unique_name`] numbers names. +#[derive(Default)] +struct FileNames { + taken: std::collections::HashSet, + /// The last number tried for each repeated name. + last: std::collections::HashMap, +} + +impl FileNames { + fn unique(&mut self, base: String) -> String { + if self.taken.insert(base.clone()) { + return base; + } + // Each pass tries the next number, and only finitely many names are taken. + let n = self.last.entry(base.clone()).or_insert(1); + loop { + *n += 1; + let name = format!("{base} {n}"); + if self.taken.insert(name.clone()) { + return name; + } + } + } +} + +pub(super) fn save_swatches(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let names: Option> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); + let chosen: Vec = d.swatches.iter().filter(|w| names.as_ref().is_none_or(|n| n.contains(&w.name))).cloned().collect(); + let bytes = designcraft_color::ase::write(&chosen); + match str_param(p, "path") { + Some(path) => { + crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?; + Ok(json!({"path": path, "bytes": bytes.len()})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&bytes)})), + } +} + +pub(super) fn add_to_swatches(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let name = match str_param(p, "swatch") { + Some(n) => n.to_string(), + None => { + let id = st.selection.items.first().copied().ok_or_else(|| bad("swatch.addToSwatches", "select an object or give `swatch`"))?; + let it = st.doc.item(id).ok_or_else(|| bad("swatch.addToSwatches", "no such item"))?; + if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } + } + }; + let rename = str_param(p, "name").map(str::to_string); + let result = s.edit(|d, _| { + let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; + if w.locked { + return Err(bad("swatch.addToSwatches", "special swatches are already listed")); + } + w.hidden = false; + let Some(new) = rename.filter(|n| *n != name) else { return Ok(json!({"name": name})) }; + if d.swatches.iter().any(|w| w.name == new) { + return Err(bad("swatch.addToSwatches", format!("a swatch named `{new}` exists"))); + } + check_text_nesting(d, "swatch.addToSwatches")?; + let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; + w.name = new.clone(); + w.named = true; + d.rename_swatch_refs(&name, &new); + Ok(json!({"name": new})) + })?; + if let Some(new) = result.get("name").and_then(Value::as_str) { + crate::cmd::text::rename_typing_swatch(s, &name, new); + } + Ok(result) +} + +/// Swatch Options: rename, Name with Color Value, colour type and value, a tint swatch's tint +/// and base — one undo step. Every use of the swatch follows a rename. +pub(super) fn swatch_options(s: &mut Session, p: &Value) -> Result { + const ID: &str = "swatch.options"; + let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?.to_string(); + let to = str_param(p, "to").map(|n| n.trim().to_string()); + let with_value = p.get("nameWithValue").and_then(Value::as_bool); + let color = match p.get("color") { + Some(c) => Some(parse_color(c).ok_or_else(|| bad(ID, "bad color"))?), + None => None, + }; + let spot = p.get("spot").and_then(Value::as_bool); + let tint = match p.get("tint") { + Some(v) => Some(v.as_f64().filter(|t| t.is_finite() && (0.0..=1.0).contains(t)).ok_or_else(|| bad(ID, "tint is 0–1"))? as f32), + None => None, + }; + let base = str_param(p, "base").map(str::to_string); + let result = s.edit(|d, _| { + let w = d.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; + let mut w2 = w.clone(); + if w.locked { + if to.as_ref().is_some_and(|t| *t != name) || with_value == Some(true) { + return Err(bad(ID, format!("`{name}` can't be renamed"))); + } + match (&mut w2.value, color) { + (_, None) => {} + (SwatchValue::Paper { color: c }, Some(new)) => *c = new, + _ => return Err(bad(ID, format!("`{name}` can't be edited"))), + } + if spot.is_some() || tint.is_some() || base.is_some() { + return Err(bad(ID, format!("`{name}` can't be edited"))); + } + } else { + match &mut w2.value { + SwatchValue::Color { color: c, color_type } => { + if let Some(new) = color { + *c = new; + } + if let Some(sp) = spot { + *color_type = if sp { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }; + } + if tint.is_some() || base.is_some() { + return Err(bad(ID, "only tint swatches have a tint")); + } + } + SwatchValue::Tint { base: b, tint: t } => { + if color.is_some() || spot.is_some() { + return Err(bad(ID, "a tint swatch takes its colour from its base")); + } + if let Some(v) = tint { + *t = v; + } + if let Some(nb) = &base { + if !d.swatch(nb).is_some_and(|x| matches!(x.value, SwatchValue::Color { .. })) || *nb == name { + return Err(bad(ID, format!("`{nb}` isn't a color swatch"))); + } + *b = nb.clone(); + } + } + _ => { + if color.is_some() || spot.is_some() || tint.is_some() || base.is_some() { + return Err(bad(ID, format!("`{name}` only takes a name here"))); + } + } + } + // Name with Color Value: the name is the colour's values and tracks later edits. + let auto = with_value.unwrap_or(!w.named && to.as_ref().is_none_or(|t| *t == name)); + let derived = match (&w2.value, auto) { + (SwatchValue::Color { color, .. }, true) => Some(value_name(*color)), + _ => None, + }; + match derived { + Some(n) => { + w2.name = n; + w2.named = false; + } + None => { + if let Some(t) = &to { + if t.is_empty() { + return Err(bad(ID, "a swatch needs a name")); + } + w2.name = t.clone(); + } + if with_value == Some(false) || w2.name != name { + w2.named = true; + } + } + } + if w2.name != name && d.swatch(&w2.name).is_some() { + return Err(bad(ID, format!("a swatch named `{}` exists", w2.name))); + } + } + let new = w2.name.clone(); + if new != name { + check_text_nesting(d, ID)?; + } + let slot = d.swatches.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; + *slot = w2; + d.rename_swatch_refs(&name, &new); + d.refresh_gradient_stops(); + Ok(json!({"name": new})) + })?; + if let Some(new) = result.get("name").and_then(Value::as_str) { + crate::cmd::text::rename_typing_swatch(s, &name, new); + } + Ok(result) +} + +#[cfg(test)] +mod group_sample_tests { + use serde_json::json; + + use crate::Session; + + /// Grouping the sample's table styles keeps its table cells styled (no missing styles). + #[test] + fn grouping_reaches_table_cells() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let before = s.execute("preflight.run", &json!({})).unwrap()["errors"].as_u64().unwrap(); + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Table Head", "Table Body"], "group": "Tables"})).unwrap(); + let after = s.execute("preflight.run", &json!({})).unwrap(); + assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}"); + } +} + +#[cfg(test)] +mod color_group_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn color_groups_hold_swatches_and_round_trip() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("swatch.create", &json!({"name": "Brand Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let b = s.execute("swatch.create", &json!({"name": "Brand Blue", "color": {"c": 100, "m": 60, "y": 0, "k": 0}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let g = s.execute("swatch.newColorGroup", &json!({"name": "Brand", "swatches": [a]})).unwrap(); + assert_eq!(g["name"], "Brand"); + assert!(s.execute("swatch.newColorGroup", &json!({"swatches": ["[Black]"]})).is_err(), "special swatches stay at the top"); + s.execute("swatch.moveToGroup", &json!({"swatches": [b], "group": "Brand"})).unwrap(); + let groups = |s: &Session| s.doc().unwrap().doc.color_groups.clone(); + assert_eq!(groups(&s)[0].swatches, [a.clone(), b.clone()]); + let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + assert_eq!(back.color_groups, groups(&s), "IDML ColorGroup"); + s.execute("swatch.delete", &json!({"name": b})).unwrap(); + assert_eq!(groups(&s)[0].swatches, [a.as_str()]); + s.execute("swatch.renameColorGroup", &json!({"name": "Brand", "to": "Identity"})).unwrap(); + s.execute("swatch.ungroupColorGroup", &json!({"name": "Identity"})).unwrap(); + assert!(groups(&s).is_empty()); + assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches"); + } +} + +#[cfg(test)] +mod ink_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn ink_manager_converts_and_aliases_spots_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (n, c) in [("Pantone A", [0, 50, 100, 0]), ("Pantone B", [100, 0, 0, 0])] { + s.execute("swatch.create", &json!({"name": n, "spot": true, "color": {"c": c[0], "m": c[1], "y": c[2], "k": c[3]}})).unwrap(); + } + let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "Pantone A", "ids": [id]})).unwrap(); + let pdf = |s: &mut Session| { + let b = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + String::from_utf8_lossy(&crate::cmd::file::base64_decode(&b)).into_owned() + }; + assert!(pdf(&mut s).contains("Pantone"), "a separation"); + s.execute("ink.options", &json!({"ink": "Pantone A", "alias": "Pantone B"})).unwrap(); + let p = pdf(&mut s); + assert!(p.contains("Pantone#20B") || p.contains("Pantone B"), "aliased onto B"); + assert!(!p.contains("Pantone#20A") && !p.contains("/Pantone A")); + s.execute("ink.options", &json!({"allToProcess": true})).unwrap(); + assert!(!pdf(&mut s).contains("Pantone"), "all spots to process"); + let l = s.execute("ink.list", &json!({})).unwrap(); + assert_eq!(l["inks"][0]["alias"], "Pantone B"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert!(designcraft_pdf::inks::spot_output(&back, &designcraft_pdf::inks::ink_set(&back), "Pantone A").1, "IDML ConvertToProcess"); + assert!(back.inks.aliases.iter().any(|(a, b)| a == "Pantone A" && b == "Pantone B"), "IDML AliasInkName"); + assert!(s.execute("ink.options", &json!({"ink": "[Black]", "toProcess": true})).is_err()); + assert!(s.execute("ink.options", &json!({"ink": "Pantone B", "alias": "Pantone A"})).is_err(), "an alias loop is refused"); + } +} + +#[cfg(test)] +mod swatch_options_tests { + use designcraft_color::SwatchValue; + use designcraft_doc::{ItemId, StoryId}; + use serde_json::json; + + use crate::Session; + + /// A document whose frame, text, styles, tint swatch and colour group all use "Brand". + fn brand_doc() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 100, "k": 0}})).unwrap(); + s.execute("swatch.create", &json!({"name": "Other", "color": "#108080"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + let (id, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + s.execute("object.fill", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fill": "Brand"}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.select", &json!({"what": "table"})).unwrap(); + s.execute("table.setCell", &json!({"stroke": {"weight": 2, "type": "dashed", "gapColor": "Brand", "edges": ["all"]}})).unwrap(); + s.execute("swatch.newColorGroup", &json!({"name": "Brand Colors", "swatches": ["Brand"]})).unwrap(); + s.execute("style.character.create", &json!({"name": "Accent"})).unwrap(); + s.edit(|d, _| { + d.swatches.push(designcraft_color::Swatch { + name: "Brand 50%".into(), + value: SwatchValue::Tint { base: "Brand".into(), tint: 0.5 }, + locked: false, + named: true, + hidden: false, + }); + let st = d.styles_mut(); + if let Some(p) = st.para_mut(designcraft_doc::BASIC_PARAGRAPH) { + p.chars.underline_color = Some("Brand".into()); + p.para.shading_color = Some("Brand".into()); + p.para.border_gap_color = Some("Brand".into()); + p.chars.ruby_stroke = Some("Brand".into()); + } + if let Some(c) = st.char_style_mut("Accent") { + c.chars.fill = Some("Brand".into()); + c.chars.kenten_fill = Some("Brand".into()); + } + Ok(()) + }) + .unwrap(); + (s, id, sid) + } + + fn uses(s: &Session, id: u64, sid: u64, name: &str) -> Vec { + let d = &s.doc().unwrap().doc; + let it = d.item(ItemId(id)).unwrap(); + let story = d.story(StoryId(sid)).unwrap(); + let basic = d.styles.para(designcraft_doc::BASIC_PARAGRAPH).unwrap(); + vec![ + it.fill.swatch == name, + it.stroke.swatch == name, + story.chars.iter().any(|r| r.format.over.fill.as_deref() == Some(name)), + basic.chars.underline_color.as_deref() == Some(name), + basic.para.shading_color.as_deref() == Some(name), + d.styles.char_style("Accent").unwrap().chars.fill.as_deref() == Some(name), + matches!(&d.swatch("Brand 50%").unwrap().value, SwatchValue::Tint { base, .. } if base == name), + d.color_groups[0].swatches == [name], + basic.para.border_gap_color.as_deref() == Some(name), + basic.chars.ruby_stroke.as_deref() == Some(name), + d.styles.char_style("Accent").unwrap().chars.kenten_fill.as_deref() == Some(name), + story.tables.values().any(|t| t.cells.iter().all(|c| c.strokes.iter().all(|e| e.gap_color == name))), + ] + } + + #[test] + fn renaming_a_swatch_renames_every_use_in_one_undo_step() { + let (mut s, id, sid) = brand_doc(); + assert!(uses(&s, id, sid, "Brand").iter().all(|u| *u)); + let r = s.execute("swatch.options", &json!({"name": "Brand", "to": "Signal Orange", "spot": true})).unwrap(); + assert_eq!(r["name"], "Signal Orange"); + let d = &s.doc().unwrap().doc; + assert!(d.swatch("Brand").is_none()); + assert!(matches!(d.swatch("Signal Orange").unwrap().value, SwatchValue::Color { color_type: designcraft_color::ColorType::Spot, .. })); + assert_eq!(uses(&s, id, sid, "Signal Orange"), vec![true; 12]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("Brand").is_some()); + assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); + } + + #[test] + fn bad_swatch_renames_change_nothing() { + let (mut s, id, sid) = brand_doc(); + let before = s.doc().unwrap().doc.clone(); + for p in [ + json!({"name": "Brand", "to": "Other"}), + json!({"name": "Brand", "to": " "}), + json!({"name": "Brand", "to": "[Paper]"}), + json!({"name": "[Black]", "to": "Ink"}), + json!({"name": "[Registration]", "to": "Reg"}), + json!({"name": "[None]", "to": "Nothing"}), + json!({"name": "[Black]", "color": "#ff0000"}), + json!({"name": "Brand 50%", "color": "#ff0000"}), + json!({"name": "Missing", "to": "X"}), + ] { + assert!(s.execute("swatch.options", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().doc.swatches, before.swatches); + assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); + // [Paper]'s colour can change; its name can't. + s.execute("swatch.options", &json!({"name": "[Paper]", "to": "[Paper]", "color": "#fff8e0"})).unwrap(); + } + + #[test] + fn name_with_color_value_tracks_the_colour() { + let (mut s, id, sid) = brand_doc(); + let r = s.execute("swatch.options", &json!({"name": "Brand", "nameWithValue": true, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); + assert_eq!(r["name"], "C=10 M=20 Y=30 K=40"); + assert_eq!(uses(&s, id, sid, "C=10 M=20 Y=30 K=40"), vec![true; 12]); + // Later colour edits keep the name in step until the swatch is named by hand. + let r = s.execute("swatch.options", &json!({"name": "C=10 M=20 Y=30 K=40", "color": {"c": 0, "m": 0, "y": 0, "k": 50}})).unwrap(); + assert_eq!(r["name"], "C=0 M=0 Y=0 K=50"); + let r = s.execute("swatch.options", &json!({"name": "C=0 M=0 Y=0 K=50", "to": "Warm Gray"})).unwrap(); + assert_eq!(r["name"], "Warm Gray"); + assert!(s.doc().unwrap().doc.swatch("Warm Gray").unwrap().named); + } +} + +#[cfg(test)] +mod sample_color_tests { + use designcraft_color::{Color, SwatchValue}; + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. + fn session_with_image() -> (Session, u64) { + let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = crate::cmd::file::base64_encode(&png); + let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); + (s, r["id"].as_u64().unwrap()) + } + + fn swatch_color(s: &Session, name: &str) -> Color { + match &s.doc().unwrap().doc.swatch(name).unwrap().value { + SwatchValue::Color { color, .. } => *color, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_eyedropper_colours_selected_text_with_the_image_under_it() { + let (mut s, image) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); + assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let name = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); + assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); + assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); + // The frame itself is not coloured. + assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); + } + + #[test] + fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { + let (mut s, _) = session_with_image(); + let lid = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); + s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + assert_eq!(r["color"], "#ff0000", "{r}"); + let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); + assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); + // Sampling the same colour again reuses its swatch. + let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); + assert_eq!(again["swatch"], r["swatch"]); + } + + #[test] + fn a_caret_takes_the_sample_for_typing_and_nothing_to_sample_is_refused() { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + // A caret: the sampled colour is for the next typing. + let r = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], r["swatch"]); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing"); + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(1).over.fill.as_deref(), r["swatch"].as_str(), "{r}"); + assert_eq!(st.format_after(0).over.fill, None, "the text before the caret keeps its colour"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); + assert!(none.contains("no placed image"), "{none}"); + // Just past the image's right edge. + assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn where_no_image_pixel_shows_the_clicked_frame_is_matched_instead() { + let (mut s, image) = session_with_image(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [image]})).unwrap(); + // The frame reaches 72 pt below its image, and a clipping path shows the left (red) pixel only. + s.edit(|d, _| { + let it = d.item_mut(ItemId(image)).ok_or(crate::EngineError::NoDocument)?; + let b = it.inner_bounds(); + it.path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1 + 72.0)); + let designcraft_doc::Content::Graphic(g) = &mut it.content else { return Err(crate::EngineError::NoDocument) }; + let left = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, g.size.0 / 2.0, g.size.1)); + g.clip = Some(std::sync::Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, left))); + Ok(()) + }) + .unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "matchFrom": image})).unwrap(); + assert_eq!(r["color"], "#ff0000", "inside the clipping path the image is sampled"); + // The clipped-out blue pixel and the empty frame below the image: nothing to sample, so the + // frame's attributes apply. + for at in [[180, 100], [100, 180]] { + assert!(s.execute("color.sample", &json!({"at": at})).is_err(), "{at:?}"); + s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [rect]})).unwrap(); + s.execute("color.sample", &json!({"at": at, "matchFrom": image})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(rect)).unwrap().fill.swatch, "[Black]", "{at:?}"); + } + } + + #[test] + fn sampling_and_explicit_colour_use_the_same_text_application_path() { + for command in ["color.sample", "object.color"] { + // Each command gets its own key: the eyedropper samples at `at`, object.color takes `color`. + let params = |at: [f64; 2], color: &str, extra: serde_json::Value| { + let mut p = if command == "color.sample" { json!({"at": at}) } else { json!({"color": color}) }; + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + p + }; + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); + let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(command, ¶ms([100.0, 100.0], "#ff0000", json!({}))).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); + s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); + // Explicit ids override text targeting, including with a text selection active. + let fid = ItemId(f["id"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"id": fid.0}))).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); + let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"target": "stroke"}))).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); + } + } + + #[test] + fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { + let (mut s, _) = session_with_image(); + let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [line]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); + assert!(it.fill.is_none(), "the fill stays empty"); + } +} + +#[cfg(test)] +mod radial_gradient_tests { + use serde_json::json; + + /// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on + /// a 200×240 page, rendered at 1 px/pt. + fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0]) + } + + #[test] + fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); + s.execute( + "object.gradient", + &json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut r = designcraft_render::Renderer::new(); + let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}"); + // The PDF, placed back and drawn: the same ellipse. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let d = p.doc().unwrap().doc.clone(); + let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); + } + + #[test] + fn a_box_with_no_height_or_width_has_a_circle_across_its_length() { + let f = designcraft_doc::Fill::swatch("g"); + let line = designcraft_geom::Rect::new(20.0, 60.0, 180.0, 60.0); + let (c, rx, ry) = f.radial_ellipse(line); + assert_eq!((c.x, c.y, rx, ry), (100.0, 60.0, 80.0, 80.0)); + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(10.0, 0.0, 10.0, 50.0)); + assert_eq!((rx, ry), (25.0, 25.0)); + // A point: a tiny circle, never a zero radius. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(5.0, 5.0, 5.0, 5.0)); + assert!(rx > 0.0 && rx == ry); + // A box keeps its ellipse. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)); + assert_eq!((rx, ry), (50.0, 100.0)); + } +} + +#[cfg(test)] +mod gradient_swatch_tests { + use designcraft_color::SwatchValue; + use serde_json::json; + + fn doc() -> crate::Session { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s + } + + #[test] + fn a_gradient_swatch_from_colour_swatches() { + let mut s = doc(); + // As the Gradient panel's stops take swatches dragged onto them. + let r = s + .execute( + "swatch.create", + &json!({"gradient": {"stops": [{"location": 100, "swatch": "C=100 M=0 Y=0 K=0"}, {"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}]}}), + ) + .unwrap(); + assert_eq!(r["name"], "New Gradient Swatch"); + let d = &s.doc().unwrap().doc; + let w = d.swatch("New Gradient Swatch").unwrap(); + assert!(w.named && !w.hidden); + let SwatchValue::Gradient { gradient } = &w.value else { panic!("{w:?}") }; + assert_eq!(gradient.stops[0].color, d.resolve_color("C=0 M=100 Y=0 K=0", 1.0).unwrap(), "sorted: magenta first"); + assert_eq!(gradient.stops[1].color, d.resolve_color("C=100 M=0 Y=0 K=0", 1.0).unwrap()); + // A second one gets its own name; bad stops are refused. + let r = s + .execute( + "swatch.create", + &json!({"gradient": {"kind": "radial", "stops": [{"color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}}), + ) + .unwrap(); + assert_eq!(r["name"], "New Gradient Swatch 2"); + for bad in [ + json!({"stops": [{"swatch": "C=0 M=100 Y=0 K=0"}]}), + json!({"stops": [{"swatch": "Nope"}, {"swatch": "[Black]"}]}), + json!({"stops": [{"swatch": "New Gradient Swatch"}, {"swatch": "[Black]"}]}), + json!({"kind": "conic", "stops": [{"color": "#000000"}, {"color": "#ffffff"}]}), + ] { + assert!(s.execute("swatch.create", &json!({"gradient": bad})).is_err(), "{bad}"); + } + } + + #[test] + fn gradient_stops_follow_their_swatch() { + let mut s = doc(); + s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap(); + s.execute( + "swatch.create", + &json!({"name": "G", "gradient": {"stops": [{"location": 0, "swatch": "Gold"}, {"location": 100, "swatch": "[Paper]"}]}}), + ) + .unwrap(); + let stops = |s: &crate::Session| match &s.doc().unwrap().doc.swatch("G").unwrap().value { + SwatchValue::Gradient { gradient } => gradient.stops.clone(), + v => panic!("{v:?}"), + }; + assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold")); + assert_eq!(stops(&s)[1].swatch.as_deref(), Some("[Paper]")); + // Renamed and edited: the stop names the new name and shows the new color. + s.execute("swatch.options", &json!({"name": "Gold", "to": "Gold 871", "color": {"c": 0, "m": 20, "y": 80, "k": 20}})).unwrap(); + assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold 871")); + assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2)); + // Deleted: the stop keeps its color as a plain one. + s.execute("swatch.delete", &json!({"name": "Gold 871"})).unwrap(); + assert_eq!(stops(&s)[0].swatch, None); + assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2)); + } + + #[test] + fn new_swatch_names_the_selections_gradient_or_duplicates_a_swatch() { + let mut s = doc(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + // A gradient made in the Gradient panel is unnamed (and not listed) … + s.execute("object.gradient", &json!({"ids": [id], "stops": [{"location": 0, "color": {"c": 0, "m": 100, "y": 0, "k": 0}}, {"location": 100, "color": {"c": 100, "m": 0, "y": 0, "k": 0}}]})).unwrap(); + let fill = |s: &crate::Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); + assert!(s.doc().unwrap().doc.swatch(&fill(&s)).unwrap().hidden); + // … until New Swatch: "New Gradient Swatch", listed, still the object's fill. + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch"); + assert_eq!(fill(&s), "New Gradient Swatch"); + let w = s.doc().unwrap().doc.swatch("New Gradient Swatch").unwrap().clone(); + assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Gradient { .. })); + // A named swatch: a copy. + assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch copy"); + assert_eq!(s.execute("swatch.new", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy"); + assert!(s.execute("swatch.new", &json!({"swatch": "[None]"})).is_err()); + // The stroke in front: the stroke's swatch. + s.execute("object.stroke", &json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + assert_eq!(s.execute("swatch.new", &json!({"target": "stroke"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy 2"); + // Nothing selected, no swatch given: a new colour swatch. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let n = s.execute("swatch.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + let w = s.doc().unwrap().doc.swatch(&n).unwrap(); + assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Color { .. }), "{w:?}"); + } +} + +#[cfg(test)] +mod gradient_stroke_tests { + use serde_json::json; + + /// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end. + fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0]) + } + + #[test] + fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let r = s + .execute( + "object.gradient", + &json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap()); + assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone"); + let mut rr = designcraft_render::Renderer::new(); + let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}"); + // The PDF, placed back and drawn: the same. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}"); + // The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left. + s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a > 215 && b < 40, "dragged: {a} {b}"); + assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); + } + + /// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF. + fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let mut g = g; + g["ids"] = json!([id]); + g["target"] = json!("stroke"); + s.execute("object.gradient", &g).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + (screen, placed) + } + + #[test] + fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() { + // The line's box has no height: the gradient is a circle across the line's length. + let (screen, pdf) = line_with_stroke_gradient( + json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ); + for (what, img) in [("screen", &screen), ("pdf", &pdf)] { + let (a, m, b) = along(img); + assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}"); + } + } + + #[test] + fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() { + let (screen, pdf) = + line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})); + let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60)); + // The line's box has no height: the gradient runs across the line's length, so the stroke + // shows its middle. + assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}"); + assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}"); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 54d8e5a411..2a124e7a91 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -17,2486 +17,15 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, f64_or, has_doc, has_text, ok, str_param}; use crate::{Result, Session}; -pub fn specs() -> Vec { - let mut v = style_specs(); - v.extend(table_specs()); - v -} - -fn style_specs() -> Vec { - vec![ - cmd!( - "style.cell.create", - "New Cell Style…", - [], - None, - "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", - has_doc, - |s, p| cell_style_create(s, p) - ), - cmd!( - "style.cell.apply", - "Apply Cell Style", - [], - None, - "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — the target cells (the whole table when the cursor is in it)", - in_table, - cell_style_apply - ), - cmd!( - "style.cell.edit", - "Cell Style Options…", - [], - None, - "{name, …same as style.cell.create} — cells using the style follow", - has_doc, - cell_style_edit - ), - cmd!( - "style.table.create", - "New Table Style…", - [], - None, - "{name, basedOn?: table style name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", - has_doc, - table_style_create - ), - cmd!( - "style.table.apply", - "Apply Table Style", - [], - None, - "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?}", - in_table, - table_style_apply - ), - cmd!( - "style.table.edit", - "Table Style Options…", - [], - None, - "{name, …same as style.table.create} — tables using the style follow", - has_doc, - table_style_edit - ), - ] -} - -fn table_specs() -> Vec { - vec![ - cmd!( - "table.insert", - "Create Table", - [], - Some("Cmd+Alt+Shift+T"), - "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", - has_text, - insert - ), - cmd!( - "table.convertFromText", - "Convert Text to Table", - ["Table"], - None, - "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", - has_text, - convert_from_text - ), - cmd!( - "table.convertToText", - "Convert Table to Text", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", - in_table, - convert_to_text - ), - cmd!( - "table.insertRow", - "Insert Row", - [], - None, - "{where?: above|below, count? (1), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_row(s, p, None) - ), - cmd!( - "table.insertRowAbove", - "Insert Row Above", - ["Table", "Insert"], - None, - "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_row(s, p, Some(true)) - ), - cmd!( - "table.insertRowBelow", - "Insert Row Below", - ["Table", "Insert"], - None, - "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_row(s, p, Some(false)) - ), - cmd!( - "table.insertColumn", - "Insert Column", - [], - None, - "{where?: left|right, count? (1), width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_col(s, p, None) - ), - cmd!( - "table.insertColumnLeft", - "Insert Column Left", - ["Table", "Insert"], - None, - "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_col(s, p, Some(true)) - ), - cmd!( - "table.insertColumnRight", - "Insert Column Right", - ["Table", "Insert"], - None, - "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_col(s, p, Some(false)) - ), - cmd!( - "table.deleteRow", - "Delete Row", - ["Table", "Delete"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the rows of the target cells", - in_table, - delete_rows - ), - cmd!( - "table.deleteColumn", - "Delete Column", - ["Table", "Delete"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the columns of the target cells", - in_table, - delete_cols - ), - cmd!( - "table.delete", - "Delete Table", - ["Table", "Delete"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - delete_table - ), - cmd!( - "table.merge", - "Merge Cells", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — merge the target cell range", - in_table, - merge - ), - cmd!( - "table.dropCells", - "Drag Rows or Columns", - [], - None, - "{frame, from: [x, y], to: [x, y]} — selected whole rows (or columns) dragged from `from` move to the row (column) at `to`; otherwise a text drag", - has_doc, - drop_cells - ), - cmd!( - "table.placeGraphic", - "Convert Cell to Graphic Cell", - ["Table", "Convert Cell Type"], - None, - "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", - in_table, - place_graphic - ), - cmd!( - "table.textCell", - "Convert Cell to Text Cell", - ["Table", "Convert Cell Type"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", - in_table, - |s, p| { - let g = target(s, p, "table.textCell")?; - edit_table(s, &g, "table.textCell", |t| { - for (r, c) in t.selected_owners(g.range) { - if let Some(cell) = t.cell_mut(r, c) { - cell.graphic = None; - } - } - ok() - }) - } - ), - cmd!( - "table.moveRow", - "Move Row", - [], - None, - "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target row) — like dragging a row", - in_table, - |s, p| move_rc(s, p, true) - ), - cmd!( - "table.moveColumn", - "Move Column", - [], - None, - "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target column)", - in_table, - |s, p| move_rc(s, p, false) - ), - cmd!( - "table.sortRows", - "Sort", - ["Table"], - None, - "{column?: 0-based (default: the target cell's), descending?: bool, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — body rows by that column's text (numbers numerically)", - in_table, - sort_rows - ), - cmd!( - "table.unmerge", - "Unmerge Cells", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - unmerge - ), - cmd!( - "table.splitHorizontally", - "Split Cell Horizontally", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, one above the other", - in_table, - |s, p| split(s, p, true) - ), - cmd!( - "table.splitVertically", - "Split Cell Vertically", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, side by side", - in_table, - |s, p| split(s, p, false) - ), - cmd!( - "table.setCell", - "Cell Options", - [], - None, - "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", - in_table, - set_cell - ), - cmd!( - query "table.getCellStroke", - "Get Cell Stroke", - [], - None, - "{story?, table?, rows?, cols?, edges?} → {weight, color, tint, type, gapColor, gapTint, fill, fillTint, edges: count} of the chosen edges of the target cells; a value that differs between them is null", - in_table_or_ids, - get_cell_stroke - ), - cmd!( - "table.setRowHeight", - "Row Height", - [], - None, - "{height, mode?: atLeast|exactly, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - set_row_height - ), - cmd!( - "table.setColumnWidth", - "Column Width", - [], - None, - "{width, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - set_col_width - ), - cmd!( - "table.resizeBorder", - "Resize Row or Column", - [], - None, - "{story?, table?, frame?, axis: column|row, index, delta? | size?, keepTableSize?: bool} — moves the border after column/row `index` by `delta` (or makes that column/row `size` wide/high); later columns/rows move along. The last index is the table's end/bottom edge. keepTableSize: on an inner border the next column/row gives or takes the change; on the edge all columns/rows scale proportionally. A dragged row becomes At Least. Refuses a locked `frame`", - in_table_or_ids, - resize_border - ), - cmd!( - "table.distributeColumns", - "Distribute Columns Evenly", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - distribute_cols - ), - cmd!( - "table.distributeRows", - "Distribute Rows Evenly", - ["Table"], - None, - "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first", - in_table, - distribute_rows - ), - cmd!( - "table.options", - "Table Options", - [], - None, - "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - options - ), - cmd!( - "table.convertRowsToHeader", - "To Header", - ["Table", "Convert Rows"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the top of the table or right after its header rows", - |s| can_convert_rows(s, RowKind::Header), - |s, p| convert_rows(s, p, RowKind::Header) - ), - cmd!( - "table.convertRowsToBody", - "To Body", - ["Table", "Convert Rows"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target header rows next to the body, footer rows next to it, or both", - |s| can_convert_rows(s, RowKind::Body), - |s, p| convert_rows(s, p, RowKind::Body) - ), - cmd!( - "table.convertRowsToFooter", - "To Footer", - ["Table", "Convert Rows"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the bottom of the table or right before its footer rows", - |s| can_convert_rows(s, RowKind::Footer), - |s, p| convert_rows(s, p, RowKind::Footer) - ), - cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, what?: cell|row|column|table}", in_table_or_ids, select), - cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { - select(s, &super::with_param(p, "what", json!("table"))) - }), - cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { - select(s, &super::with_param(p, "what", json!("row"))) - }), - cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { - select(s, &super::with_param(p, "what", json!("column"))) - }), - cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), - cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), - cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), - ] -} - -// ---------- enablement and targeting ---------- - -fn in_table(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - let st = s.active().ok_or("no document open")?; - if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { - return Ok(()); - } - if let Some(t) = st.selection.text - && st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some()) - { - return Ok(()); - } - Err("place the insertion point in a table".into()) -} - -fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { - has_doc(s) -} - -fn in_cell(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } -} - -/// The table a command acts on and its target cell range. -struct Tgt { - story: StoryId, - table: u64, - range: CellRange, -} - -fn find_table(doc: &Document, id: u64) -> Option { - doc.stories.iter().find(|(_, s)| s.tables.contains_key(&id)).map(|(k, _)| *k) -} - -fn pair(p: &Value, many: &str, one: &str) -> Option<(usize, usize)> { - if let Some(a) = p.get(many).and_then(Value::as_array) { - let a0 = a.first()?.as_u64()? as usize; - let a1 = a.get(1).and_then(Value::as_u64).map_or(a0, |v| v as usize); - return Some((a0.min(a1), a0.max(a1))); - } - p.get(one).and_then(Value::as_u64).map(|v| (v as usize, v as usize)) -} - -fn target(s: &Session, p: &Value, cmd: &str) -> Result { - let st = s.doc()?; - let doc = &st.doc; - let explicit_table = p.get("table").and_then(Value::as_u64); - let explicit_story = p.get("story").and_then(Value::as_u64).map(StoryId); - let (story, table, range) = if let Some(tid) = explicit_table { - let sid = explicit_story.or_else(|| find_table(doc, tid)).ok_or_else(|| bad(cmd, format!("no table {tid}")))?; - (sid, tid, None) - } else if let Some(c) = st.selection.cells { - (c.story, c.table, Some(c.range)) - } else if let Some(t) = st.selection.text { - match t.cell { - Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))), - None => { - let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?; - (t.story, tid, None) - } - } - } else { - return Err(bad(cmd, "place the insertion point in a table")); - }; - let t = doc.story(story).and_then(|x| x.tables.get(&table)).ok_or_else(|| bad(cmd, format!("no table {table}")))?; - let (nr, nc) = (t.nrows(), t.ncols()); - let mut range = range.unwrap_or(CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }); - if let Some((a, b)) = pair(p, "rows", "row") { - range.r0 = a; - range.r1 = b; - } - if let Some((a, b)) = pair(p, "cols", "col") { - range.c0 = a; - range.c1 = b; - } - if range.r1 >= nr || range.c1 >= nc { - return Err(bad(cmd, format!("cell range out of bounds ({nr}×{nc} table)"))); - } - Ok(Tgt { story, table, range }) -} - -/// Edit the target table; afterwards selections pointing into it are clamped to its new shape. -fn edit_table(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result) -> Result { - let cmd = cmd.to_string(); - s.edit(|d, sel| { - let st = d.story_mut(g.story).ok_or_else(|| bad(&cmd, "no story"))?; - let t = st.table_mut(g.table).ok_or_else(|| bad(&cmd, "no table"))?; - let r = f(t)?; - clamp_selection(d, sel); - Ok(r) - }) -} - -/// Keep text/cell selections valid after the table changed shape (or disappeared). -fn clamp_selection(d: &Document, sel: &mut Selection) { - if let Some(ts) = sel.cells { - match d.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { - Some(t) => { - let (mr, mc) = (t.nrows() - 1, t.ncols() - 1); - let r = ts.range; - sel.cells = Some(TableSel { range: CellRange::new(r.r0.min(mr), r.c0.min(mc), r.r1.min(mr), r.c1.min(mc)), ..ts }); - } - None => sel.cells = None, - } - } - if let Some(t) = sel.text - && let Some(c) = t.cell - { - let story = d.story(t.story); - match story.and_then(|x| x.tables.get(&c.table)) { - Some(tb) => { - let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); - let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); - sel.text = Some(TextSel { - anchor: floor_char_boundary(text, t.anchor), - focus: floor_char_boundary(text, t.focus), - cell: Some(CellAddr { table: c.table, row: r, col }), - ..t - }); - } - None => { - let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); - sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); - } - } - } -} - -fn count(p: &Value) -> usize { - p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize -} - -fn color_param(p: &Value, key: &str) -> Option { - str_param(p, key).map(str::to_string) -} - -// ---------- create / convert ---------- - -/// Width of the text column at the caret (the new table spans it). -fn column_width(s: &Session, t: &TextSel) -> f64 { - let Some(st) = s.active() else { return 300.0 }; - let specs = compose::frame_specs(&st.doc, t.story); - let spec = t.frame.and_then(|f| specs.iter().find(|x| x.id == f)).or(specs.first()); - spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0) -} - -/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style -/// chain, then local overrides). A table created there takes it. -fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection { - d.story(story) - .and_then(|st| st.paras.get(st.para_at(offset))) - .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) -} - -fn insert(s: &mut Session, p: &Value) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; - if t.cell.is_some() { - return Err(bad("table.insert", "tables can't be nested in cells")); - } - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; - let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; - let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); - s.edit(|d, sel| { - let id = d.alloc(); - let mut table = Table::new(id, rows, cols, header, footer, width); - table.options.direction = para_direction(d, t.story, t.range().start); - let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?; - // Cells start with the paragraph format at the caret. - let pf = st.paras[st.para_at(t.range().start)].clone(); - let cf = st.char_format_at(t.range().start).clone(); - for c in &mut table.cells { - c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; - c.text.chars[0].format = cf.clone(); - } - let r = t.range(); - st.delete(r.clone()); - st.insert_table(r.start, table); - *sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) }); - Ok(json!({"story": t.story.0, "table": id})) - }) -} - -fn convert_from_text(s: &mut Session, p: &Value) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; - if t.cell.is_some() { - return Err(bad("table.convertFromText", "select text outside tables")); - } - let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; - let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); - s.edit(|d, sel| { - let id = d.alloc(); - let direction = para_direction(d, t.story, t.range().start); - let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?; - let ranges = st.para_ranges(); - let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end)); - if (pa..=pb).any(|i| st.paras[i].table.is_some()) { - return Err(bad("table.convertFromText", "the selection contains a table")); - } - let (a, b) = (ranges[pa].start, ranges[pb].end); - let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); - let pf = st.paras[pa].clone(); - let cf = st.format_after(a).clone(); - let mut table = Table::from_strings(id, &data, width, &pf, &cf); - table.options.direction = direction; - st.delete(a..b); - let anchor = st.insert_table(a, table); - *sel = Selection::text(TextSel::caret(t.story, anchor)); - Ok(json!({"table": id, "rows": data.len()})) - }) -} - -fn convert_to_text(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.convertToText")?; - s.edit(|d, sel| { - let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; - let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); - let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; - st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); - *sel = Selection::text(TextSel::caret(g.story, a + text.len())); - ok() - }) -} - -fn delete_table(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.delete")?; - s.edit(|d, sel| { - let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?; - let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?; - // Remove the anchor paragraph (with one separator) so no empty paragraph is left behind. - let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8(); - let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') { - // Inserted at the end of the story: remove the empty paragraph after it too. - a - 1..end + 1 - } else if st.text[end..].starts_with('\n') { - a..end + 1 - } else if a > 0 && st.text[..a].ends_with('\n') { - a - 1..end - } else { - a..end - }; - st.delete(r.clone()); - *sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len()))); - ok() - }) -} - -// ---------- rows and columns ---------- - -fn insert_row(s: &mut Session, p: &Value, above: Option) -> Result { - let g = target(s, p, "table.insertRow")?; - let above = above.unwrap_or(str_param(p, "where") == Some("above")); - let n = count(p); - let at = if above { g.range.r0 } else { g.range.r1 + 1 }; - let r = edit_table(s, &g, "table.insertRow", |t| { - t.insert_rows(at, n); - Ok(json!({"rows": t.nrows()})) - })?; - shift_selection(s, g.table, at, n, true); - Ok(r) -} - -/// After inserting rows/columns at `at`, selections at or after it follow their cells. -fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool) { - let Ok(st) = s.doc_mut() else { return }; - if let Some(t) = st.selection.text.as_mut() - && let Some(c) = t.cell.as_mut() - && c.table == table - { - let v = if rows { &mut c.row } else { &mut c.col }; - if *v >= at { - *v += n; - } - } - if let Some(ts) = st.selection.cells.as_mut() - && ts.table == table - { - let (a, b) = if rows { (&mut ts.range.r0, &mut ts.range.r1) } else { (&mut ts.range.c0, &mut ts.range.c1) }; - if *a >= at { - *a += n; - } - if *b >= at { - *b += n; - } - } -} - -fn insert_col(s: &mut Session, p: &Value, left: Option) -> Result { - let g = target(s, p, "table.insertColumn")?; - let left = left.unwrap_or(str_param(p, "where") == Some("left")); - let rtl = s - .doc()? - .doc - .story(g.story) - .and_then(|st| st.tables.get(&g.table)) - .is_some_and(|t| t.options.direction == designcraft_doc::TextDirection::RightToLeft); - let n = count(p); - let at = if left != rtl { g.range.c0 } else { g.range.c1 + 1 }; - let width = p.get("width").and_then(Value::as_f64); - let r = edit_table(s, &g, "table.insertColumn", |t| { - let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width); - t.insert_cols(at, n, w); - Ok(json!({"columns": t.ncols()})) - })?; - shift_selection(s, g.table, at, n, false); - Ok(r) -} - -fn delete_rows(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.deleteRow")?; - let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.r0 == 0 && g.range.r1 + 1 >= t.nrows()); - if all { - return delete_table(s, p); - } - edit_table(s, &g, "table.deleteRow", |t| { - t.delete_rows(g.range.r0, g.range.r1 - g.range.r0 + 1); - Ok(json!({"rows": t.nrows()})) - }) -} - -fn delete_cols(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.deleteColumn")?; - let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.c0 == 0 && g.range.c1 + 1 >= t.ncols()); - if all { - return delete_table(s, p); - } - edit_table(s, &g, "table.deleteColumn", |t| { - t.delete_cols(g.range.c0, g.range.c1 - g.range.c0 + 1); - Ok(json!({"columns": t.ncols()})) - }) -} - -fn merge(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.merge")?; - let r = edit_table(s, &g, "table.merge", |t| { - t.merge(g.range).map_err(|e| bad("table.merge", e))?; - Ok(t.expand_range(g.range)) - })?; - // The caret goes to the merged cell. - let st = s.doc_mut()?; - st.selection = Selection::text(TextSel { - story: g.story, - anchor: 0, - focus: 0, - frame: st.selection.text.and_then(|t| t.frame), - cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }), - }); - ok() -} - -fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { - let g = target(s, p, "table.split")?; - edit_table(s, &g, "table.split", |t| { - t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; - Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) - }) -} - -fn unmerge(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.unmerge")?; - edit_table(s, &g, "table.unmerge", |t| { - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - t.unmerge(r, c); - } - } - ok() - }) -} - -/// Cell edges chosen for a stroke edit: the target range's four outer sides (physical left and -/// right) and the lines between its rows and columns. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -struct EdgeSel { - top: bool, - left: bool, - bottom: bool, - right: bool, - inner_h: bool, - inner_v: bool, -} - -impl EdgeSel { - const ALL: EdgeSel = EdgeSel { top: true, left: true, bottom: true, right: true, inner_h: true, inner_v: true }; - - fn parse(v: Option<&Value>, cmd: &str) -> Result { - let names: Vec<&Value> = match v { - None | Some(Value::Null) => return Ok(EdgeSel::ALL), - Some(Value::Array(a)) => a.iter().collect(), - Some(v) => vec![v], - }; - let mut e = EdgeSel::default(); - for n in names { - match n.as_str() { - Some("all") => e = EdgeSel::ALL, - Some("outer") => (e.top, e.left, e.bottom, e.right) = (true, true, true, true), - Some("inner") => (e.inner_h, e.inner_v) = (true, true), - Some("top") => e.top = true, - Some("left") => e.left = true, - Some("bottom") => e.bottom = true, - Some("right") => e.right = true, - Some("innerHorizontal") => e.inner_h = true, - Some("innerVertical") => e.inner_v = true, - _ => return Err(bad(cmd, format!("unknown edge {n}; use all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical"))), - } - } - if e == EdgeSel::default() { - return Err(bad(cmd, "no edges chosen")); - } - Ok(e) - } - - /// Which sides (top, left, bottom, right) of the cell at `(r, c)` with `spans` are chosen - /// within `rg`. Left and right are physical sides, also in RTL tables. - fn sides(&self, rg: CellRange, (r, c): (usize, usize), spans: (u32, u32), rtl: bool) -> [bool; 4] { - let last_row = r.saturating_add((spans.0.max(1) - 1) as usize); - let last_col = c.saturating_add((spans.1.max(1) - 1) as usize); - let (start, end) = (c <= rg.c0, last_col >= rg.c1); - let (left_outer, right_outer) = if rtl { (end, start) } else { (start, end) }; - [ - if r <= rg.r0 { self.top } else { self.inner_h }, - if left_outer { self.left } else { self.inner_v }, - if last_row >= rg.r1 { self.bottom } else { self.inner_h }, - if right_outer { self.right } else { self.inner_v }, - ] - } -} - -/// A finite number in `lo..=hi`, or an error naming `key`. -fn num_in(v: &Value, key: &str, lo: f64, hi: f64, cmd: &str) -> Result> { - match v.get(key) { - None | Some(Value::Null) => Ok(None), - Some(x) => match x.as_f64() { - Some(n) if n.is_finite() && (lo..=hi).contains(&n) => Ok(Some(n)), - _ => Err(bad(cmd, format!("{key} must be a number from {lo} to {hi}"))), - }, - } -} - -/// A swatch of the document (or [None]), or an error naming `key`. -fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result> { - let Some((key, x)) = keys.iter().find_map(|k| v.get(*k).filter(|x| !x.is_null()).map(|x| (*k, x))) else { return Ok(None) }; - let name = x.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a swatch name")))?; - if name != designcraft_color::swatch::NONE && doc.swatch(name).is_none() { - return Err(bad(cmd, format!("no swatch `{name}`"))); - } - Ok(Some(name.to_string())) -} - -/// The cell stroke type names of `type`. -const CELL_STROKE_TYPES: &[&str] = - &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; - -/// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the -/// resulting weight of each edge. -struct StrokeEdit { - attrs: designcraft_doc::CellStrokeAttrs, - scaled_dashes: bool, -} - -impl StrokeEdit { - fn parse(doc: &Document, v: &Value, cmd: &str) -> Result { - if !v.is_object() { - return Err(bad(cmd, "stroke must be an object")); - } - let mut attrs = designcraft_doc::CellStrokeAttrs { - weight: num_in(v, "weight", 0.0, 1000.0, cmd)?, - color: swatch_in(doc, v, &["color"], cmd)?, - tint: num_in(v, "tint", 0.0, 1.0, cmd)?.map(|t| t as f32), - gap_color: swatch_in(doc, v, &["gapColor", "gapSwatch"], cmd)?, - gap_tint: num_in(v, "gapTint", 0.0, 1.0, cmd)?.map(|t| t as f32), - kind: None, - }; - let mut scaled_dashes = false; - match v.get("type") { - None | Some(Value::Null) => {} - Some(Value::String(name)) if name == "dashed" => { - scaled_dashes = true; - attrs.kind = Some(StrokeType::Dashed { pattern: Vec::new() }); - } - Some(Value::String(name)) if CELL_STROKE_TYPES.contains(&name.as_str()) => { - attrs.kind = Some(serde_json::from_value(json!({"kind": name})).map_err(|e| bad(cmd, e.to_string()))?); - } - Some(t @ Value::Object(_)) => { - let kind: StrokeType = serde_json::from_value(t.clone()).map_err(|e| bad(cmd, format!("bad stroke type: {e}")))?; - let finite = |v: &[f64]| v.len() <= 64 && v.iter().all(|x| x.is_finite() && *x >= 0.0); - match &kind { - StrokeType::Dashed { pattern } if !StrokeType::valid_dash_pattern(pattern) => { - return Err(bad(cmd, "a dash pattern needs 1 to 64 lengths, each 0 or 0.1 to 10000 pt, not all 0")); - } - StrokeType::Stripes { bands } if bands.len() > 64 || !bands.iter().all(|(a, b)| finite(&[*a, *b])) => { - return Err(bad(cmd, "stripes need up to 64 bands of non-negative numbers")); - } - StrokeType::Style { name } if !doc.stroke_styles.iter().any(|s| s.name == *name) => { - return Err(bad(cmd, format!("no stroke style `{name}`"))); - } - _ => {} - } - attrs.kind = Some(kind); - } - Some(other) => return Err(bad(cmd, format!("unknown stroke type {other}"))), - } - Ok(StrokeEdit { attrs, scaled_dashes }) - } - - fn apply(&self, mut base: CellStroke) -> CellStroke { - self.attrs.apply_to(&mut base); - if self.scaled_dashes { - let w = base.weight.max(1.0); - base.kind = StrokeType::Dashed { pattern: vec![w * 3.0, w * 2.0] }; - } - base - } -} - -/// The stroke drawn on side `side` of the owner cell at `(r, c)` (where that side meets its -/// first neighbour), as rendering resolves it; the cell's own copy if the side has no stretch. -fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side: usize) -> Option { - let drawn = compose::table::cell_side_strokes(t, owners, (r, c), side).first().map(|s| s.stroke.clone()); - drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned())) -} - -/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt). -fn first_baseline_params(p: &Value, id: &str) -> Result<(Option, Option)> { - let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) { - Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?), - None => None, - }; - let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) { - Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) { - Some(m) => Some(m), - None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")), - }, - None => None, - }; - Ok((fb, min)) -} - -fn set_cell(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.setCell"; - let g = target(s, p, ID)?; - let doc = &s.doc()?.doc; - let fill = swatch_in(doc, p, &["fill"], ID)?; - let tint = num_in(p, "tint", 0.0, 1.0, ID)?.map(|t| t as f32); - let insets: Option<[f64; 4]> = match p.get("insets") { - Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]), - Some(Value::Array(a)) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); - Some([v[0], v[1], v[2], v[3]]) - } - _ => None, - }; - let vj = match str_param(p, "vj").or_else(|| str_param(p, "verticalJustification")) { - Some("top") => Some(VerticalJustification::Top), - Some("center") => Some(VerticalJustification::Center), - Some("bottom") => Some(VerticalJustification::Bottom), - Some("justify") => Some(VerticalJustification::Justify), - Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), - None => None, - }; - let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; - // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is - // composed so far, which the IDML import reports (#1468). - let rotation = match p.get("rotation").filter(|v| !v.is_null()) { - Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) { - Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d), - _ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")), - }, - None => None, - }; - let text = str_param(p, "text").map(|_| super::text_param(p, "text")); - let stroke = match p.get("stroke").filter(|v| !v.is_null()) { - Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), - None => None, - }; - edit_table(s, &g, ID, |t| { - let owners = t.owners(); - let nc = t.ncols(); - let nr = t.nrows(); - let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; - // Local edits outrank previously imported edges, including explicit zero/None. - let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; - let rg = g.range; - // Edges are inner or outer within the selection grown to whole merged cells, so a - // merged cell selected through one of its grid positions keeps its own outline. - let outline = t.expand_range(rg); - // Each chosen edge starts from the stroke drawn there, which both copies of a shared - // edge then take, so the edit changes only the attributes it names. - let mut bases = std::collections::HashMap::new(); - if let Some((_, edges)) = &stroke { - for (r, c) in t.selected_owners(rg) { - let Some(cell) = t.cell(r, c) else { continue }; - for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { - if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) { - bases.insert((r, c, i), base); - } - } - } - } - let mut edited = std::collections::HashSet::new(); - for (r, c) in t.selected_owners(rg) { - let Some(cell) = t.cell_mut(r, c) else { continue }; - if let Some(f) = &fill { - cell.fill = f.clone(); - } - if let Some(ti) = tint { - cell.fill_tint = ti; - } - if let Some(i) = insets { - cell.insets = i; - } - if let Some(v) = vj { - cell.vj = v; - } - if let Some(v) = first_baseline { - cell.first_baseline = v; - } - if let Some(v) = first_baseline_min { - cell.first_baseline_min = v; - } - if let Some(v) = rotation { - cell.rotation = v; - } - if let Some(tx) = &text { - let len = cell.text.len(); - cell.text.replace(0..len, tx); - } - if let Some((edit, _)) = &stroke { - for i in 0..4 { - if let Some(base) = bases.remove(&(r, c, i)) { - edited.insert((r, c, i)); - cell.strokes[i] = edit.apply(base); - cell.stroke_defined[i] = true; - cell.stroke_priorities[i] = priority; - cell.border_overrides[i] = true; - } - } - } - } - // Keep both copies of unmerged shared boundaries consistent: each edited edge is - // copied to its neighbour. Left/right name physical sides, also in RTL tables. - let mirror = |t: &mut Table, from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize| { - if edited.contains(&(from.0, from.1, edge)) { - mirror_cell_edge(t, &owners, from, edge, to, opposite); - } - }; - if let Some((_, edges)) = &stroke { - if edges.top - && let Some(row) = rg.r0.checked_sub(1) - { - for c in rg.c0..=rg.c1 { - mirror(t, (rg.r0, c), 0, (row, c), 2); - } - } - if edges.bottom && rg.r1.saturating_add(1) < nr { - for c in rg.c0..=rg.c1 { - mirror(t, (rg.r1, c), 2, (rg.r1 + 1, c), 0); - } - } - if (if rtl { edges.right } else { edges.left }) - && let Some(col) = rg.c0.checked_sub(1) - { - for r in rg.r0..=rg.r1 { - mirror(t, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); - } - } - if (if rtl { edges.left } else { edges.right }) && rg.c1.saturating_add(1) < nc { - for r in rg.r0..=rg.r1 { - mirror(t, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); - } - } - } - ok() - }) -} - -/// The shown stroke attributes of the chosen edges of the target cells, and their fill; values -/// that differ are null. -fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.getCellStroke"; - let g = target(s, p, ID)?; - let edges = EdgeSel::parse(p.get("edges"), ID)?; - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - let owners = t.owners(); - let outline = t.expand_range(g.range); - let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; - // One value per key, or null once two edges (cells) disagree. - fn merge(slot: &mut Option, v: Value) { - match slot { - None => *slot = Some(v), - Some(cur) if *cur != v => *cur = Value::Null, - _ => {} - } - } - let keys = ["weight", "color", "tint", "type", "gapColor", "gapTint"]; - let mut stroke: [Option; 6] = Default::default(); - let (mut fill, mut fill_tint) = (None, None); - // Each drawn edge once: a shared edge is a side of both its cells. - let mut seen = std::collections::HashSet::new(); - for (r, c) in t.selected_owners(g.range) { - let Some(cell) = t.cell(r, c) else { continue }; - merge(&mut fill, json!(cell.fill)); - merge(&mut fill_tint, json!(cell.fill_tint)); - for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { - if !on { - continue; - } - for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) { - let (x, y) = (Some((r, c)), side.neighbor); - if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) { - continue; - } - let e = side.stroke; - let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; - for (slot, v) in stroke.iter_mut().zip(values) { - merge(slot, v); - } - } - } - } - let mut out = serde_json::Map::new(); - for (k, v) in keys.iter().zip(stroke) { - out.insert((*k).into(), v.unwrap_or(Value::Null)); - } - out.insert("fill".into(), fill.unwrap_or(Value::Null)); - out.insert("fillTint".into(), fill_tint.unwrap_or(Value::Null)); - out.insert("edges".into(), json!(seen.len())); - Ok(Value::Object(out)) -} - -/// Only saturation needs rebasing. Preserve every distinct priority's order and keep -/// zero/negative priorities untouched; the work is bounded by the table's edges. -fn next_cell_stroke_priority(t: &mut Table) -> i32 { - let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0); - if highest < i32::MAX { - return highest + 1; - } - let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect(); - let mut ranks = std::collections::BTreeMap::new(); - let mut highest = 0i32; - for value in values { - highest = highest.saturating_add(1); - ranks.insert(value, highest); - } - for cell in &mut t.cells { - for priority in &mut cell.stroke_priorities { - if let Some(rank) = ranks.get(priority) { - *priority = *rank; - } - } - } - highest.saturating_add(1) -} - -fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) { - let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied(); - if owner(from) != Some(from) || owner(to) != Some(to) { - return; - } - // A merged cell's edge may border several separately editable cells. Mirroring - // a segment onto that whole edge would change unselected neighbors; priorities - // instead resolve each segment without altering the merged cell's formatting. - let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1; - if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) { - return; - } - let source = t - .cell(from.0, from.1) - .map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge])); - if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) { - neighbor.strokes[opposite] = stroke; - neighbor.stroke_defined[opposite] = defined; - neighbor.stroke_priorities[opposite] = priority; - neighbor.border_overrides[opposite] = border_override; - } -} - -fn set_row_height(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.setRowHeight")?; - let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0)); - let mode = match str_param(p, "mode") { - Some("exactly") => Some(RowHeightMode::Exactly), - Some("atLeast") => Some(RowHeightMode::AtLeast), - Some(o) => return Err(bad("table.setRowHeight", format!("unknown mode `{o}`"))), - None => None, - }; - if h.is_none() && mode.is_none() { - return Err(bad("table.setRowHeight", "missing height")); - } - edit_table(s, &g, "table.setRowHeight", |t| { - for row in &mut t.rows[g.range.r0..=g.range.r1] { - if let Some(h) = h { - row.height = h; - } - if let Some(m) = mode { - row.mode = m; - } - } - ok() - }) -} - -fn set_col_width(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.setColumnWidth")?; - let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad("table.setColumnWidth", "missing width"))?.clamp(3.0, 10000.0); - edit_table(s, &g, "table.setColumnWidth", |t| { - for c in &mut t.columns[g.range.c0..=g.range.c1] { - c.width = w; - } - ok() - }) -} - -/// Smallest column width and row height a border drag leaves. -const MIN_TRACK: f64 = 3.0; -const MAX_TRACK: f64 = 10000.0; - -/// Composed height of `row` (laid out from cells that span only that row), else its model height. -fn composed_row_height(cs: &compose::ComposedStory, t: &Table, row: usize) -> Option { - let laid_out = cs - .frames - .iter() - .flat_map(|f| &f.tables) - .filter(|f| f.table == t.id) - .flat_map(|f| &f.cells) - .filter(|c| c.row == row && t.cell(c.row, c.col).is_some_and(|x| x.row_span <= 1)) - .map(|c| c.rect.height()) - .filter(|h| h.is_finite() && *h > 0.0) - .reduce(f64::max); - laid_out.or_else(|| t.rows.get(row).map(|r| r.height)) -} - -/// Move the border after `index` among `sizes` by `delta`. Returns the new sizes. -fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option> { - let mut out = sizes.to_vec(); - let cur = *sizes.get(index)?; - let last = index + 1 == sizes.len(); - if keep && last { - // Scale every track so the table edge moves by `delta`. - let total: f64 = sizes.iter().sum(); - let smallest = sizes.iter().copied().fold(f64::INFINITY, f64::min); - let largest = sizes.iter().copied().fold(0.0, f64::max); - if !(total > 0.0 && smallest > 0.0 && largest > 0.0) { - return None; - } - let lo = (MIN_TRACK / smallest).min(1.0); - let hi = (MAX_TRACK / largest).max(1.0); - let f = ((total + delta) / total).clamp(lo, hi); - out.iter_mut().for_each(|v| *v *= f); - } else if keep { - let next = *sizes.get(index + 1)?; - let pair = cur + next; - let a = (cur + delta).clamp(MIN_TRACK, (pair - MIN_TRACK).max(MIN_TRACK)); - let [this, after] = out.get_mut(index..index + 2)? else { return None }; - *this = a; - *after = (pair - a).max(MIN_TRACK); - } else { - *out.get_mut(index)? = (cur + delta).clamp(MIN_TRACK, MAX_TRACK); - } - out.iter().all(|v| v.is_finite()).then_some(out) -} - -/// The frame a border drag names must hold the table's story and be editable. -fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> { - const ID: &str = "table.resizeBorder"; - if let Some(fid) = super::id_param(p, "frame") { - let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?; - if it.text_frame().map(|f| f.story) != Some(story) { - return Err(bad(ID, "the table is not in that frame's story")); - } - if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) { - return Err(bad(ID, "the frame or its layer is locked")); - } - } - Ok(()) -} - -/// The change in track size: `delta`, or `size` measured from the current size `cur`. -fn border_delta(p: &Value, cur: f64) -> Result { - const ID: &str = "table.resizeBorder"; - let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) { - (Some(d), _) => d, - (None, Some(sz)) => sz - cur, - (None, None) => return Err(bad(ID, "`delta` or `size` required")), - }; - if !delta.is_finite() { - return Err(bad(ID, "`delta`/`size` must be a finite number")); - } - Ok(delta) -} - -fn resize_border(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.resizeBorder"; - let column = match str_param(p, "axis") { - Some("column") => true, - Some("row") => false, - _ => return Err(bad(ID, "axis must be `column` or `row`")), - }; - let index = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(ID, "`index` required"))?; - let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false); - let g = target(s, p, ID)?; - let st = s.doc()?; - check_border_frame(st, p, g.story)?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - let sizes: Vec = if column { - t.columns.iter().map(|c| c.width).collect() - } else { - let cs = s.cache.get(&st.doc, g.story, None); - (0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect() - }; - let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?; - let delta = border_delta(p, cur)?; - let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?; - let size = new.get(index).copied().unwrap_or(cur); - let scaled = keep && index + 1 == sizes.len(); - edit_table(s, &g, ID, |t| { - if column { - for (c, w) in t.columns.iter_mut().zip(&new) { - c.width = *w; - } - } else { - for (i, (r, h)) in t.rows.iter_mut().zip(&new).enumerate() { - // Only the rows the drag changed take the new height. - if scaled || i == index || (keep && i == index + 1) { - r.height = *h; - } - if i == index && !scaled { - r.mode = RowHeightMode::AtLeast; - } - } - } - Ok(json!({"axis": if column { "column" } else { "row" }, "index": index, "size": size})) - }) -} - -fn distribute_rows(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.distributeRows"; - if !p.is_object() { - return Err(bad(ID, "parameters must be an object")); - } - if let Some(rows) = p.get("rows") { - let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()))); - if !valid { - return Err(bad(ID, "rows must be two non-negative row indices")); - } - } - for key in ["row", "story", "table"] { - if let Some(v) = p.get(key) - && v.as_u64().is_none_or(|n| usize::try_from(n).is_err()) - { - return Err(bad(ID, format!("{key} must be a non-negative integer"))); - } - } - let g = target(s, p, ID)?; - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - if t.cells.iter().any(|c| c.row_span > 1) { - return Err(bad(ID, "unmerge cells that span rows first")); - } - let cs = s.cache.get(&st.doc, g.story, None); - let mut total = 0.0; - for row in g.range.r0..=g.range.r1 { - let height = cs - .frames - .iter() - .flat_map(|f| &f.tables) - .filter(|f| f.table == g.table) - .flat_map(|f| &f.cells) - .filter(|c| c.row == row) - .map(|c| c.rect.height()) - .reduce(f64::max) - .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?; - if !height.is_finite() || height <= 0.0 { - return Err(bad(ID, "invalid composed row height")); - } - total += height; - } - let count = g.range.r1 - g.range.r0 + 1; - let height = total / count as f64; - if !height.is_finite() { - return Err(bad(ID, "invalid total row height")); - } - edit_table(s, &g, ID, |t| { - let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?; - for row in rows { - row.height = height; - row.mode = RowHeightMode::Exactly; - } - Ok(json!({"rows": count, "height": height})) - }) -} - -fn distribute_cols(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.distributeColumns")?; - edit_table(s, &g, "table.distributeColumns", |t| { - let cols = &mut t.columns[g.range.c0..=g.range.c1]; - let avg = cols.iter().map(|c| c.width).sum::() / cols.len() as f64; - cols.iter_mut().for_each(|c| c.width = avg); - ok() - }) -} - -fn parse_alt(v: &Value) -> Option { - if v.is_null() { - return None; - } - let d = AltFills::default(); - Some(AltFills { - first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)), - first_color: color_param(v, "firstColor").unwrap_or(d.first_color), - first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32), - next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)), - next_color: color_param(v, "nextColor").unwrap_or(d.next_color), - next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32), - skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), - skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), - }) -} - -fn options(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.options")?; - let border = match p.get("border").filter(|v| !v.is_null()) { - Some(b) => Some(StrokeEdit::parse(&s.doc()?.doc, b, "table.options")?), - None => None, - }; - let p = p.clone(); - edit_table(s, &g, "table.options", |t| { - let o = &mut t.options; - if let Some(v) = p.get("direction") { - o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?; - } - if let Some(b) = &border { - o.border = b.apply(o.border.clone()); - o.borders = Default::default(); - } - o.space_before = f64_or(&p, "spaceBefore", o.space_before); - o.space_after = f64_or(&p, "spaceAfter", o.space_after); - if let Some(b) = p.get("repeatHeader").and_then(Value::as_bool) { - o.repeat_header = b; - } - if let Some(b) = p.get("repeatFooter").and_then(Value::as_bool) { - o.repeat_footer = b; - } - if let Some(v) = p.get("altRows") { - o.alt_rows = parse_alt(v); - } - if let Some(v) = p.get("altCols") { - o.alt_cols = parse_alt(v); - } - // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. - let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); - let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); - if h.is_some() || f.is_some() { - let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); - // Add rows when there aren't enough for the requested header/footer plus one body row. - let need = h + f + 1; - if t.nrows() < need { - let at = t.nrows(); - t.insert_rows(at, need - at); - } - t.set_header_footer(h, f); - } - Ok(json!({"options": t.options})) - }) -} - -/// Convert Rows is greyed out unless the selected cells (or the caret's cell) can change kind. -fn can_convert_rows(s: &Session, kind: RowKind) -> std::result::Result<(), String> { - let st = s.active().ok_or("no document open")?; - if st.selection.cells.is_none() { - in_cell(s)?; - } - let g = target(s, &Value::Null, "table.convertRows").map_err(|e| e.to_string())?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or("no table")?; - t.convert_rows_counts(g.range.r0, g.range.r1, kind).map(|_| ()) -} - -fn convert_rows(s: &mut Session, p: &Value, kind: RowKind) -> Result { - const ID: &str = "table.convertRows"; - let g = target(s, p, ID)?; - edit_table(s, &g, ID, |t| { - let (h, f) = t.convert_rows_counts(g.range.r0, g.range.r1, kind).map_err(|e| bad(ID, e))?; - t.set_header_footer(h, f); - Ok(json!({"headerRows": h, "footerRows": f})) - }) -} - -// ---------- selection ---------- - -fn select(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.select")?; - let (nr, nc) = { - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.select", "no table"))?; - (t.nrows(), t.ncols()) - }; - let mut r = g.range; - match str_param(p, "what") { - Some("table") => r = CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }, - Some("row") => { - r.c0 = 0; - r.c1 = nc - 1; - } - Some("column") => { - r.r0 = 0; - r.r1 = nr - 1; - } - _ => {} - } - let st = s.doc_mut()?; - let frame = st.selection.text.and_then(|t| t.frame); - st.selection = Selection { - cells: Some(TableSel { story: g.story, table: g.table, range: r }), - text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), - ..Default::default() - }; - st.bump_revision(); - Ok(json!({"story": g.story.0, "table": g.table, "range": r})) -} - -/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last -/// cell adds a row. -pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.nextCell", "no caret"))?; - let c = t.cell.ok_or_else(|| bad("table.nextCell", "not in a cell"))?; - let (owners, nr, nc) = { - let st = s.doc()?; - let tb = st.doc.story(t.story).and_then(|x| x.tables.get(&c.table)).ok_or_else(|| bad("table.nextCell", "no table"))?; - (tb.owners(), tb.nrows(), tb.ncols()) - }; - let cur = c.row * nc + c.col; - let next = - if forward { (cur + 1..nr * nc).find(|&i| owners[i] == (i / nc, i % nc)) } else { (0..cur).rev().find(|&i| owners[i] == (i / nc, i % nc)) }; - let (row, col) = match next { - Some(i) => (i / nc, i % nc), - None if forward => { - let g = Tgt { story: t.story, table: c.table, range: CellRange::cell(nr - 1, 0) }; - edit_table(s, &g, "table.nextCell", |tb| { - tb.insert_rows(nr, 1); - ok() - })?; - (nr, 0) - } - None => return ok(), - }; - let st = s.doc_mut()?; - let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len()); - st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t }); - st.bump_revision(); - Ok(json!({"row": row, "col": col})) -} - -fn get(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.get")?; - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?; - let owners = t.owners(); - let cells: Vec = (0..t.nrows()) - .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) - .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) - .filter_map(|(r, c)| { - let cell = t.cell(r, c)?; - Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) - }) - .collect(); - Ok(json!({ - "story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)), - "rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(), - "cells": cells, "options": t.options, "range": g.range, "style": t.style, - })) -} - -// ---------- cell and table styles ---------- - -/// A style's uniform edge stroke from `stroke`/`border`: the given values over the default -/// edge, validated like `table.setCell`'s stroke. -fn stroke_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result> { - match v.filter(|v| !v.is_null()) { - Some(v) => Ok(Some(StrokeEdit::parse(&s.doc()?.doc, v, cmd)?.apply(CellStroke::default()))), - None => Ok(None), - } -} - -/// Fill in the cell style fields given in `p`. -fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Option, cmd: &str) -> Result<()> { - if let Some(v) = p.get("basedOn") { - cs.based_on = v.as_str().map(str::to_string); - } - if let Some(f) = color_param(p, "fill") { - cs.fill = Some(f); - } - if let Some(t) = p.get("tint").and_then(Value::as_f64) { - cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32); - } - match p.get("insets") { - Some(Value::Number(n)) => { - cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]); - cs.inset_overrides = Default::default(); - } - Some(Value::Array(a)) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); - cs.insets = Some([v[0], v[1], v[2], v[3]]); - cs.inset_overrides = Default::default(); - } - _ => {} - } - if let Some(v) = p.get("vj") { - cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); - } - let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?; - if first_baseline.is_some() { - cs.first_baseline = first_baseline; - } - if first_baseline_min.is_some() { - cs.first_baseline_min = first_baseline_min; - } - if let Some(st) = stroke { - cs.stroke = Some(st); - cs.strokes = Default::default(); - } - if let Some(ps) = str_param(p, "paragraphStyle") { - cs.paragraph_style = Some(ps.to_string()); - } - Ok(()) -} - -fn cell_style_create(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Cell Style 1").to_string(); - let mut cs = designcraft_doc::CellStyle::default(); - if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) { - let g = target(s, p, "style.cell.create")?; - let d = &s.doc()?.doc; - let cell = d - .story(g.story) - .and_then(|st| st.tables.get(&g.table)) - .and_then(|t| { - let (r, c) = t.owner(g.range.r0, g.range.c0); - t.cell(r, c) - }) - .ok_or_else(|| bad("style.cell.create", "no cell"))?; - cs.fill = Some(cell.fill.clone()); - cs.fill_tint = Some(cell.fill_tint); - cs.insets = Some(cell.insets); - cs.vj = Some(cell.vj); - cs.first_baseline = Some(cell.first_baseline); - cs.first_baseline_min = Some(cell.first_baseline_min); - cs.stroke = Some(cell.strokes[0].clone()); - cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); - } - let stroke = stroke_param(s, p.get("stroke"), "style.cell.create")?; - cell_style_fields(&mut cs, p, stroke, "style.cell.create")?; - s.edit(|d, _| { - let name = designcraft_doc::Styles::unique_name(|n| d.styles.cell.iter().any(|c| c.name == n), &base); - cs.name = name.clone(); - d.styles_mut().cell.push(cs); - Ok(json!({"name": name})) - }) -} - -fn cell_style_apply(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string(); - let styles = &s.doc()?.doc.styles.cell; - if !styles.iter().any(|c| c.name == name) { - return Err(bad("style.cell.apply", format!("no cell style `{name}`"))); - } - let cs = designcraft_doc::resolve_cell_style(styles, &name); - let g = target(s, p, "style.cell.apply")?; - edit_table(s, &g, "style.cell.apply", |t| { - let mut n = 0; - for (r, c) in t.selected_owners(g.range) { - if let Some(cell) = t.cell_mut(r, c) { - cs.apply_to(cell); - n += 1; - } - } - Ok(json!({"cells": n})) - }) -} - -/// Re-apply cell styles named in `names` to every cell using them (after a style edit). -fn reapply_cell_styles(d: &mut Document, names: &[String]) { - let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect(); - for sid in d.stories.keys().copied().collect::>() { - let Some(st) = d.story_mut(sid) else { continue }; - for t in st.tables.values_mut() { - if !t.cells.iter().any(|c| names.contains(&c.style)) { - continue; - } - for cell in &mut std::sync::Arc::make_mut(t).cells { - if names.contains(&cell.style) - && let Some(cs) = styles.iter().find(|x| x.name == cell.style) - { - cs.apply_to(cell); - } - } - } - st.rev = st.rev.wrapping_add(1); - } -} - -/// Include descendants when editing an inherited style, with the resolver's depth limit. -fn dependent_styles<'a>(name: &str, styles: impl Iterator)>) -> Vec { - let styles: Vec<_> = styles.collect(); - styles - .iter() - .filter(|(candidate, _)| { - let mut current = Some(*candidate); - for _ in 0..32 { - let Some(current_name) = current else { return false }; - if current_name == name { - return true; - } - current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base); - } - false - }) - .map(|(name, _)| (*name).to_string()) - .collect() -} - -fn cell_style_edit(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); - let stroke = stroke_param(s, p.get("stroke"), "style.cell.edit")?; - let p = p.clone(); - s.edit(|d, _| { - let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?; - cell_style_fields(cs, &p, stroke, "style.cell.edit")?; - let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref()))); - reapply_cell_styles(d, &names); - ok() - }) -} - -fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: Option) { - if let Some(v) = p.get("basedOn") { - ts.based_on = v.as_str().map(str::to_string); - } - for (k, f) in [ - ("header", &mut ts.header), - ("body", &mut ts.body), - ("footer", &mut ts.footer), - ("leftColumn", &mut ts.left_column), - ("rightColumn", &mut ts.right_column), - ] { - if let Some(v) = p.get(k) { - *f = v.as_str().map(str::to_string); - } - } - if let Some(b) = border { - ts.border = Some(b); - ts.borders = Default::default(); - } - if let Some(a) = p.get("altRows").and_then(Value::as_object) { - let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX)); - let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string(); - let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32); - ts.alt_rows = Some(designcraft_doc::AltFills { - first: n("first", 1), - first_color: c("firstColor"), - first_tint: t("firstTint"), - next: n("next", 1), - next_color: c("nextColor"), - next_tint: t("nextTint"), - skip_first: n("skipFirst", 0), - skip_last: n("skipLast", 0), - }); - } - if let Some(v) = p.get("spaceBefore").and_then(Value::as_f64) { - ts.space_before = Some(v); - } - if let Some(v) = p.get("spaceAfter").and_then(Value::as_f64) { - ts.space_after = Some(v); - } -} - -fn table_style_create(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Table Style 1").to_string(); - let mut ts = designcraft_doc::TableStyle::default(); - let border = stroke_param(s, p.get("border"), "style.table.create")?; - table_style_fields(&mut ts, p, border); - s.edit(|d, _| { - let name = designcraft_doc::Styles::unique_name(|n| d.styles.table.iter().any(|c| c.name == n), &base); - ts.name = name.clone(); - d.styles_mut().table.push(ts); - Ok(json!({"name": name})) - }) -} - -fn table_style_apply(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string(); - let doc = &s.doc()?.doc; - if !doc.styles.table.iter().any(|c| c.name == name) { - return Err(bad("style.table.apply", format!("no table style `{name}`"))); - } - let ts = doc.styles.resolve_table_style(&name); - let cells = doc.styles.cell.clone(); - let g = target(s, p, "style.table.apply")?; - edit_table(s, &g, "style.table.apply", |t| { - ts.apply_to(t, &cells); - ok() - }) -} - -fn table_style_edit(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string(); - let border = stroke_param(s, p.get("border"), "style.table.edit")?; - let p = p.clone(); - s.edit(|d, _| { - let ts = - d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?; - table_style_fields(ts, &p, border); - let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref()))); - let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect(); - let cells = d.styles.cell.clone(); - for sid in d.stories.keys().copied().collect::>() { - let Some(st) = d.story_mut(sid) else { continue }; - for t in st.tables.values_mut() { - if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) { - ts.apply_to(std::sync::Arc::make_mut(t), &cells); - } - } - st.rev = st.rev.wrapping_add(1); - } - ok() - }) -} - -#[cfg(test)] -mod style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap(); - let got = s.execute("table.get", &json!({})).unwrap(); - let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone(); - assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0))); - assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err()); - // Cell Options › Text › Rotation: carried and written back, composed horizontally so far, - // which the IDML import reports (#1468). - for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] { - s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}")); - let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone(); - assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}"); - } - for bad in [45, 1, 100] { - assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}"); - } - assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err()); - s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap(); - s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap(); - let d = &s.doc().unwrap().doc; - let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap(); - assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading); - assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent); - } - - #[test] - fn cell_and_table_styles_apply_and_follow_edits() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Cell Head"})).unwrap(); - s.execute("style.cell.create", &json!({"name": "Head", "fill": "[Black]", "paragraphStyle": "Cell Head", "insets": 6})).unwrap(); - s.execute("style.cell.create", &json!({"name": "Body", "fill": "[Paper]"})).unwrap(); - s.execute("style.table.create", &json!({"name": "Data", "header": "Head", "body": "Body", "border": {"weight": 2, "color": "[Black]"}})) - .unwrap(); - s.execute("style.table.apply", &json!({"name": "Data"})).unwrap(); - let t = |s: &Session| -> designcraft_doc::Table { - let d = &s.doc().unwrap().doc; - (**d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap()).clone() - }; - let tb = t(&s); - assert_eq!(tb.style, "Data"); - assert_eq!(tb.options.border.weight, 2.0); - let head = tb.cell(0, 0).unwrap(); - assert_eq!((head.fill.as_str(), head.style.as_str(), head.insets), ("[Black]", "Head", [6.0; 4])); - assert_eq!(head.text.paras[0].style, "Cell Head"); - assert_eq!(tb.cell(1, 1).unwrap().style, "Body"); - // Editing the cell style updates its cells. - s.execute("style.cell.edit", &json!({"name": "Head", "fill": "C=100 M=0 Y=0 K=0"})).unwrap(); - assert_eq!(t(&s).cell(0, 1).unwrap().fill, "C=100 M=0 Y=0 K=0"); - // Editing the table style re-applies it. - s.execute("style.table.edit", &json!({"name": "Data", "border": {"weight": 4}})).unwrap(); - assert_eq!(t(&s).options.border.weight, 4.0); - assert!(s.execute("style.cell.apply", &json!({"name": "Nope"})).is_err()); - } -} - -/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col). -fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> { - let st = s.active()?; - let loc = st.doc.find(frame)?; - let it = st.doc.item_at(&loc)?; - let sid = it.text_frame()?.story; - let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt; - let cs = s.cache.get(&st.doc, sid, None); - let fi = cs.frames.iter().position(|f| f.frame == frame)?; - let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?; - Some((sid, table, row, col)) -} - -fn drop_cells(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.dropCells"; - let frame = super::id_param(p, "frame").ok_or_else(|| bad(ID, "`frame` required"))?; - let pt = |k: &str| -> Result { - let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(ID, format!("`{k}` required")))?; - Ok(designcraft_geom::Point::new(a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0))) - }; - let (from, to) = (pt("from")?, pt("to")?); - let sel = s.doc()?.selection.cells; - let hit_from = cell_at(s, frame, from); - let hit_to = cell_at(s, frame, to); - if let (Some(ts), Some((sid, tid, fr, fc)), Some((_, tid2, tr, tc))) = (sel, hit_from, hit_to) - && ts.story == sid - && ts.table == tid - && tid2 == tid - && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) - { - let (nr, nc) = { - let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; - (t.nrows(), t.ncols()) - }; - let r = ts.range; - let rows = r.c0 == 0 && r.c1 + 1 == nc; - let cols = r.r0 == 0 && r.r1 + 1 == nr; - if rows || cols { - let (lo, hi, target) = if rows { (r.r0, r.r1, tr) } else { (r.c0, r.c1, tc) }; - if target >= lo && target <= hi { - return Ok(json!({"moved": 0})); - } - let n = hi - lo + 1; - let r2 = s.edit(|d, selm| { - let t = d.story_mut(sid).and_then(|st| st.table_mut(tid)).ok_or_else(|| bad(ID, "no table"))?; - let mv = |t: &mut designcraft_doc::Table, a: usize, b: usize| if rows { t.move_row(a, b) } else { t.move_col(a, b) }; - let new_lo = if target > hi { - for _ in 0..n { - mv(t, lo, target).map_err(|e| bad(ID, e))?; - } - target + 1 - n - } else { - for k in 0..n { - mv(t, lo + k, target + k).map_err(|e| bad(ID, e))?; - } - target - }; - let range = - if rows { CellRange::new(new_lo, r.c0, new_lo + n - 1, r.c1) } else { CellRange::new(r.r0, new_lo, r.r1, new_lo + n - 1) }; - selm.cells = Some(TableSel { range, ..ts }); - Ok(json!({"moved": n, "to": new_lo})) - })?; - return Ok(r2); - } - } - // Not a row/column drag: select text from the press to the release. - s.execute("text.placeCaret", &json!({"frame": frame.0, "point": [from.x, from.y]}))?; - s.execute("text.extendTo", &json!({"frame": frame.0, "point": [to.x, to.y]})) -} - -fn place_graphic(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.placeGraphic"; - let g = target(s, p, ID)?; - let (bytes, name, link) = super::file::read_source(p)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; - let (nw, nh) = designcraft_render::natural_size(&bytes, 0).ok_or_else(|| bad(ID, "invalid image dimensions"))?; - let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); - // Compute fitting before the edit: a small EPS can overflow in a huge cell. - let t = s.doc()?.doc.story(g.story).and_then(|st| st.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - let (r, c) = t.owner(g.range.r0, g.range.c0); - let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?; - let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize); - let w: f64 = t.columns.get(c..c.saturating_add(cs).min(t.ncols())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.width).sum(); - let h: f64 = t.rows.get(r..r.saturating_add(rs).min(t.nrows())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.height).sum(); - let ins = cell.insets; - let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0)); - let k = if fill { (bw / nw).max(bh / nh) } else { (bw / nw).min(bh / nh) }; - let xf = designcraft_geom::Affine::translate(((bw - nw * k) / 2.0, (bh - nh * k) / 2.0)) * designcraft_geom::Affine::scale(k); - super::file::validate_placement((nw, nh), designcraft_geom::Rect::new(0.0, 0.0, bw, bh), xf, ID)?; - s.edit(|d, sel| { - let fresh = designcraft_doc::AssetId(d.alloc()); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let aid = d.add_asset(designcraft_doc::Asset { - id: fresh, - name, - mime, - link, - data: std::sync::Arc::new(bytes), - pixels: Some((pw, ph)), - ..Default::default() - }); - let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; - let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; - let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; - cell.graphic = Some(designcraft_doc::Graphic { - layer_options: None, - asset: aid, - size: (nw, nh), - xf, - clip: None, - auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - clamp_selection(d, sel); - Ok(json!({"asset": aid.0, "row": r, "col": c})) - }) -} - -fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { - const ID: &str = "table.move"; - let g = target(s, p, ID)?; - let from = p.get("from").and_then(Value::as_u64).map_or(if rows { g.range.r0 } else { g.range.c0 }, |v| v as usize); - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(ID, "`to` required"))? as usize; - edit_table(s, &g, ID, |t| { - if rows { t.move_row(from, to) } else { t.move_col(from, to) }.map_err(|e| bad(ID, e))?; - Ok(json!({"from": from, "to": to})) - }) -} - -fn sort_rows(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.sortRows")?; - let col = p.get("column").and_then(Value::as_u64).map_or(g.range.c0, |c| c as usize); - let desc = p.get("descending").and_then(Value::as_bool).unwrap_or(false); - edit_table(s, &g, "table.sortRows", |t| { - let (nr, nc) = (t.nrows(), t.ncols()); - if col >= nc { - return Err(bad("table.sortRows", format!("no column {col}"))); - } - if t.cells.iter().any(|c| c.row_span > 1) { - return Err(bad("table.sortRows", "unmerge cells that span rows first")); - } - let (h, f) = (t.header_rows(), t.footer_rows()); - let body: Vec = (h..nr - f).collect(); - let key = |r: usize| t.cell(r, col).map(|c| c.text.text.trim().to_string()).unwrap_or_default(); - let mut order = body.clone(); - order.sort_by(|a, b| { - let (ka, kb) = (key(*a), key(*b)); - let o = match (ka.replace(',', "").parse::(), kb.replace(',', "").parse::()) { - (Ok(x), Ok(y)) => x.total_cmp(&y), - _ => ka.to_lowercase().cmp(&kb.to_lowercase()), - }; - if desc { o.reverse() } else { o } - }); - let rows = t.rows.clone(); - let cells = t.cells.clone(); - for (k, src) in order.iter().enumerate() { - let dst = body[k]; - t.rows[dst] = rows[*src].clone(); - for c in 0..nc { - t.cells[dst * nc + c] = cells[src * nc + c].clone(); - } - } - Ok(json!({"sorted": body.len()})) - }) -} - -#[cfg(test)] -mod sort_tests { - use serde_json::json; - - use crate::Session; - - fn distribution_session() -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] { - s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); - s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap(); - } - s - } - - fn table(s: &Session) -> designcraft_doc::Table { - (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() - } - - #[test] - fn distribute_selected_rows_preserves_height_content_and_undo() { - let mut s = distribution_session(); - s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap(); - let before = table(&s); - let selection = s.doc().unwrap().selection.cells; - let result = s.execute("table.distributeRows", &json!({})).unwrap(); - assert_eq!(result, json!({"rows": 2, "height": 60.0})); - let after = table(&s); - assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]); - assert_eq!(after.cells, before.cells); - assert_eq!(after.rows[0].kind, before.rows[0].kind); - assert_eq!(s.doc().unwrap().selection.cells, selection); - let st = s.doc().unwrap(); - let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; - let cs = s.cache.get(&st.doc, sid, None); - let heights: Vec<_> = - cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect(); - assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(table(&s), before); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(table(&s), after); - } - - #[test] - fn distribution_uses_content_grown_heights_and_explicit_table() { - let mut s = distribution_session(); - s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap(); - s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap(); - let st = s.doc().unwrap(); - let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap(); - let (sid, tid) = (story.id, *story.tables.keys().next().unwrap()); - let cs = s.cache.get(&st.doc, sid, None); - let total: f64 = cs - .frames - .iter() - .flat_map(|f| &f.tables) - .flat_map(|f| &f.cells) - .filter(|c| c.col == 0 && (1..=2).contains(&c.row)) - .map(|c| c.rect.height()) - .sum(); - let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap(); - assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6); - assert!(table(&s).rows[1].height > 3.0); - assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly); - } - - #[test] - fn distribution_keeps_column_spans_and_caret() { - let mut s = distribution_session(); - s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); - let before = table(&s); - let caret = s.doc().unwrap().selection.text; - let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap(); - assert_eq!(result["height"], 60.0); - assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2); - assert_eq!(table(&s).cells, before.cells); - assert_eq!(s.doc().unwrap().selection.text, caret); - } - - #[test] - fn distribution_rejects_merges_and_missing_rows_without_changes() { - let mut s = distribution_session(); - s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap(); - let before = table(&s); - assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err()); - assert_eq!(table(&s), before); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap(); - let before = table(&s); - assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err()); - assert_eq!(table(&s), before); - assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err()); - for params in - [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])] - { - assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}"); - assert_eq!(table(&s), before); - } - } - - #[test] - fn sort_body_rows_by_a_column() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - let cells = [("Name", "Qty"), ("pear", "10"), ("Apple", "9"), ("fig", "100")]; - for (r, (a, b)) in cells.iter().enumerate() { - s.execute("table.setCell", &json!({"rows": [r, r], "cols": [0, 0], "text": a})).unwrap(); - s.execute("table.setCell", &json!({"rows": [r, r], "cols": [1, 1], "text": b})).unwrap(); - } - let col = |s: &Session, c: usize| -> Vec { - let d = &s.doc().unwrap().doc; - let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - (0..t.nrows()).map(|r| t.cell(r, c).unwrap().text.text.clone()).collect() - }; - s.execute("table.sortRows", &json!({"column": 0})).unwrap(); - assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive"); - s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap(); - assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value"); - s.execute("table.splitVertically", &json!({"rows": [1, 1], "cols": [0, 0]})).unwrap(); - let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); - assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3))); - } - - #[test] - fn graphic_cells_draw_their_image() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - s.execute("table.rowHeight", &json!({"height": 100})).ok(); - // A red image into the first cell. - let px: Vec = (0..40 * 40).flat_map(|_| [255, 0, 0, 255]).collect(); - let png = designcraft_render::Rendered { width: 40, height: 40, pixels: px }.to_png(); - s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": ""})).ok(); - let g = s.execute("table.placeGraphic", &json!({"base64": super::super::file::base64_encode(&png), "name": "r.png", "fit": "fill"})).unwrap(); - assert_eq!((g["row"].as_u64(), g["col"].as_u64()), (Some(0), Some(0))); - let d = s.doc().unwrap().doc.clone(); - let st = d.stories.values().find(|st| !st.tables.is_empty()).unwrap(); - let t = st.tables.values().next().unwrap(); - let cs = s.cache.get(&d, st.id, None); - let cell = cs.frames[0].tables[0].cells.iter().find(|c| c.row == 0 && c.col == 0).unwrap().clone(); - let fx = d.item(cs.frames[0].frame).unwrap().xf; - let centre = fx * cell.clip.center(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); - let p = img.pixel(centre.x as u32, centre.y as u32); - assert!(p[0] > 200 && p[1] < 60, "red in the graphic cell: {p:?}"); - assert!(t.cell(0, 0).unwrap().graphic.is_some()); - s.execute("table.textCell", &json!({})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.stories.values().flat_map(|st| st.tables.values()).all(|t| t.cell(0, 0).unwrap().graphic.is_none())); - } - - #[test] - fn move_rows_and_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap(); - for (r, c, t) in [(0, 0, "a"), (1, 0, "b"), (2, 0, "c"), (0, 1, "x")] { - s.execute("table.setCell", &json!({"rows": [r, r], "cols": [c, c], "text": t})).unwrap(); - } - let cell = |s: &Session, r: usize, c: usize| { - let d = &s.doc().unwrap().doc; - let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - t.cell(r, c).unwrap().text.text.clone() - }; - // Dragging a selected row onto the last row moves it there. - let d = s.doc().unwrap().doc.clone(); - let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); - let fid = d.stories[&sid].frames[0]; - let cs = s.cache.get(&d, sid, None); - let cells = &cs.frames[0].tables[0].cells; - let centre = |r: usize| { - let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); - d.text_xf(d.item(fid).unwrap()) * c - }; - let (from, to) = (centre(0), centre(2)); - s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); - let r2 = s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); - assert_eq!(r2["moved"], 1); - assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["a", "b", "c"]); - s.execute("table.moveRow", &json!({"from": 0, "to": 2})).unwrap(); - assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); - s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); - assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); - } - - #[test] - fn move_row_cannot_move_a_header_row_out_of_place() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err()); - s.doc().unwrap().doc.check().unwrap(); - // Dragging the selected header row onto the last body row is refused too. - let d = s.doc().unwrap().doc.clone(); - let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); - let fid = d.stories[&sid].frames[0]; - let cs = s.cache.get(&d, sid, None); - let cells = &cs.frames[0].tables[0].cells; - let centre = |r: usize| { - let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); - d.text_xf(d.item(fid).unwrap()) * c - }; - let (from, to) = (centre(0), centre(2)); - s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); - assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err()); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn table_options_limits_header_and_footer_rows() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - // The limit `table.insert` has: the table used to grow by as many rows as were asked for. - s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); - let t = table(&s); - assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); - } -} - -#[cfg(test)] -mod arabic_tests { - use super::*; - #[test] - fn arabic_table_direction_and_physical_left_insertion_support_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 400, 300], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); - s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": "Right"})).unwrap(); - s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); - s.execute("table.select", &json!({"rows": [0, 0], "cols": [0, 0]})).unwrap(); - s.execute("table.insertColumnLeft", &json!({})).unwrap(); - let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - assert_eq!(t.ncols(), 3); - assert_eq!(t.cell(0, 0).unwrap().text.text, "Right"); - assert!(t.cell(0, 1).unwrap().text.text.is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - assert_eq!(t.ncols(), 2); - assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft); - } - - /// A text frame whose paragraphs have a paragraph style with `direction`, with the story's - /// text selected. Returns the session and the story id. - fn styled_frame(direction: &str, text: &str) -> (Session, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap(); - s.execute("edit.selectAll", &json!({})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap(); - s.execute("edit.selectAll", &json!({})).unwrap(); - (s, r["story"].as_u64().unwrap()) - } - - fn only_table(s: &Session) -> std::sync::Arc { - s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone() - } - - /// Text converted to a table in a right-to-left paragraph laid its columns out left to right - /// (#100): the first column belongs on the right. - #[test] - fn tables_take_the_direction_of_their_paragraph() { - use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; - let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج"); - s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); - assert_eq!(only_table(&s).options.direction, RightToLeft); - let st = s.doc().unwrap(); - let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None); - let placed = &cs.frames[0].tables[0]; - let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect); - assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}"); - - let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc"); - s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); - assert_eq!(only_table(&s).options.direction, LeftToRight); - - for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { - let (mut s, _) = styled_frame(direction, "x"); - s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); - assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph"); - } - } -} - -#[cfg(test)] -mod resize_border_tests { - use serde_json::json; - - use crate::Session; - - /// A 3×3 table of 80 pt columns; returns the session, frame id, story and table ids. - fn session() -> (Session, u64, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 600], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - let t = s.execute("table.insert", &json!({"rows": 3, "cols": 3, "width": 240})).unwrap(); - (s, r["id"].as_u64().unwrap(), t["story"].as_u64().unwrap(), t["table"].as_u64().unwrap()) - } - - fn table(s: &Session) -> designcraft_doc::Table { - (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() - } - - fn widths(s: &Session) -> Vec { - table(s).columns.iter().map(|c| c.width).collect() - } - - /// Composed cell rects of column 0, top to bottom. - fn rows(s: &Session) -> Vec { - let st = s.doc().unwrap(); - let sid = st.doc.stories.values().find(|x| !x.tables.is_empty()).unwrap().id; - let cs = s.cache.get(&st.doc, sid, None); - cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect).collect() - } - - fn resize(s: &mut Session, ids: (u64, u64, u64), mut p: serde_json::Value) { - p["frame"] = json!(ids.0); - p["story"] = json!(ids.1); - p["table"] = json!(ids.2); - s.execute("table.resizeBorder", &p).unwrap(); - } - - #[test] - fn column_border_drags_widen_the_table_or_keep_its_width() { - let (mut s, f, st, t) = session(); - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": 20})); - assert_eq!(widths(&s), [100.0, 80.0, 80.0]); - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 30, "keepTableSize": true})); - assert_eq!(widths(&s), [100.0, 110.0, 50.0]); - // Clamped at the minimum; the neighbour takes only what is left. - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 500, "keepTableSize": true})); - assert_eq!(widths(&s), [100.0, 157.0, 3.0]); - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": -1000})); - assert_eq!(widths(&s), [3.0, 157.0, 3.0]); - // Shift on the end edge scales every column. - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 2, "size": 166, "keepTableSize": true})); - let w = widths(&s); - assert!((w.iter().sum::() - 326.0).abs() < 1e-9, "{w:?}"); - assert!((w[1] / w[0] - 157.0 / 3.0).abs() < 1e-9, "{w:?}"); - assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 3, "delta": 1})).is_err()); - assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 0})).is_err()); - } - - #[test] - fn row_drag_sets_at_least_and_moves_later_rows() { - let (mut s, f, st, t) = session(); - s.execute("table.setRowHeight", &json!({"table": t, "row": 0, "height": 30, "mode": "exactly"})).unwrap(); - let before = rows(&s); - resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": 12})); - let row = &table(&s).rows[0]; - assert_eq!((row.height, row.mode), (42.0, designcraft_doc::RowHeightMode::AtLeast)); - let after = rows(&s); - assert!((after[0].height() - 42.0).abs() < 1e-9); - assert!((after[1].y0 - before[1].y0 - 12.0).abs() < 1e-9 && (after[1].height() - before[1].height()).abs() < 1e-9); - // Below the minimum: clamped, never negative. - resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": -1000})); - assert_eq!(table(&s).rows[0].height, 3.0); - } - - #[test] - fn a_drag_is_one_undo_step_and_locked_frames_refuse() { - let (mut s, f, st, t) = session(); - let before = table(&s); - s.begin_interaction("Resize Column").unwrap(); - for d in [5.0, 15.0, 25.0] { - let p = json!({"frame": f, "story": st, "table": t, "axis": "column", "index": 0, "delta": d}); - s.preview("table.resizeBorder", p).unwrap(); - } - s.commit_interaction().unwrap(); - assert_eq!(widths(&s), [105.0, 80.0, 80.0]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(table(&s), before); - s.execute("object.setFlags", &json!({"ids": [f], "locked": true})).unwrap(); - assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); - } -} - -#[cfg(test)] -mod style_alt_rows_tests { - use serde_json::json; - - use super::table_style_fields; - - #[test] - fn style_alt_rows_counts_saturate() { - let mut ts = designcraft_doc::TableStyle::default(); - let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}}); - table_style_fields(&mut ts, &p, None); - let a = ts.alt_rows.unwrap(); - assert_eq!(a.first, u32::MAX, "first"); - assert_eq!(a.next, u32::MAX, "next"); - assert_eq!(a.skip_first, u32::MAX, "skip_first"); - assert_eq!(a.skip_last, 3, "skip_last"); - } -} +mod cell; +mod content; +mod options; +mod registry; +mod selection; +mod sizing; +mod structure; +mod styles; +mod target; + +pub use registry::specs; +pub(crate) use selection::step_cell; diff --git a/crates/engine/src/cmd/table/cell.rs b/crates/engine/src/cmd/table/cell.rs new file mode 100644 index 0000000000..6d65348e1a --- /dev/null +++ b/crates/engine/src/cmd/table/cell.rs @@ -0,0 +1,477 @@ +use super::target::{edit_table, target}; +use super::*; + +/// Cell edges chosen for a stroke edit: the target range's four outer sides (physical left and +/// right) and the lines between its rows and columns. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct EdgeSel { + top: bool, + left: bool, + bottom: bool, + right: bool, + inner_h: bool, + inner_v: bool, +} + +impl EdgeSel { + const ALL: EdgeSel = EdgeSel { top: true, left: true, bottom: true, right: true, inner_h: true, inner_v: true }; + + fn parse(v: Option<&Value>, cmd: &str) -> Result { + let names: Vec<&Value> = match v { + None | Some(Value::Null) => return Ok(EdgeSel::ALL), + Some(Value::Array(a)) => a.iter().collect(), + Some(v) => vec![v], + }; + let mut e = EdgeSel::default(); + for n in names { + match n.as_str() { + Some("all") => e = EdgeSel::ALL, + Some("outer") => (e.top, e.left, e.bottom, e.right) = (true, true, true, true), + Some("inner") => (e.inner_h, e.inner_v) = (true, true), + Some("top") => e.top = true, + Some("left") => e.left = true, + Some("bottom") => e.bottom = true, + Some("right") => e.right = true, + Some("innerHorizontal") => e.inner_h = true, + Some("innerVertical") => e.inner_v = true, + _ => return Err(bad(cmd, format!("unknown edge {n}; use all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical"))), + } + } + if e == EdgeSel::default() { + return Err(bad(cmd, "no edges chosen")); + } + Ok(e) + } + + /// Which sides (top, left, bottom, right) of the cell at `(r, c)` with `spans` are chosen + /// within `rg`. Left and right are physical sides, also in RTL tables. + fn sides(&self, rg: CellRange, (r, c): (usize, usize), spans: (u32, u32), rtl: bool) -> [bool; 4] { + let last_row = r.saturating_add((spans.0.max(1) - 1) as usize); + let last_col = c.saturating_add((spans.1.max(1) - 1) as usize); + let (start, end) = (c <= rg.c0, last_col >= rg.c1); + let (left_outer, right_outer) = if rtl { (end, start) } else { (start, end) }; + [ + if r <= rg.r0 { self.top } else { self.inner_h }, + if left_outer { self.left } else { self.inner_v }, + if last_row >= rg.r1 { self.bottom } else { self.inner_h }, + if right_outer { self.right } else { self.inner_v }, + ] + } +} + +/// A finite number in `lo..=hi`, or an error naming `key`. +fn num_in(v: &Value, key: &str, lo: f64, hi: f64, cmd: &str) -> Result> { + match v.get(key) { + None | Some(Value::Null) => Ok(None), + Some(x) => match x.as_f64() { + Some(n) if n.is_finite() && (lo..=hi).contains(&n) => Ok(Some(n)), + _ => Err(bad(cmd, format!("{key} must be a number from {lo} to {hi}"))), + }, + } +} + +/// A swatch of the document (or [None]), or an error naming `key`. +fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result> { + let Some((key, x)) = keys.iter().find_map(|k| v.get(*k).filter(|x| !x.is_null()).map(|x| (*k, x))) else { return Ok(None) }; + let name = x.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a swatch name")))?; + if name != designcraft_color::swatch::NONE && doc.swatch(name).is_none() { + return Err(bad(cmd, format!("no swatch `{name}`"))); + } + Ok(Some(name.to_string())) +} + +/// The cell stroke type names of `type`. +const CELL_STROKE_TYPES: &[&str] = + &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; + +/// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the +/// resulting weight of each edge. +pub(super) struct StrokeEdit { + attrs: designcraft_doc::CellStrokeAttrs, + scaled_dashes: bool, +} + +impl StrokeEdit { + pub(super) fn parse(doc: &Document, v: &Value, cmd: &str) -> Result { + if !v.is_object() { + return Err(bad(cmd, "stroke must be an object")); + } + let mut attrs = designcraft_doc::CellStrokeAttrs { + weight: num_in(v, "weight", 0.0, 1000.0, cmd)?, + color: swatch_in(doc, v, &["color"], cmd)?, + tint: num_in(v, "tint", 0.0, 1.0, cmd)?.map(|t| t as f32), + gap_color: swatch_in(doc, v, &["gapColor", "gapSwatch"], cmd)?, + gap_tint: num_in(v, "gapTint", 0.0, 1.0, cmd)?.map(|t| t as f32), + kind: None, + }; + let mut scaled_dashes = false; + match v.get("type") { + None | Some(Value::Null) => {} + Some(Value::String(name)) if name == "dashed" => { + scaled_dashes = true; + attrs.kind = Some(StrokeType::Dashed { pattern: Vec::new() }); + } + Some(Value::String(name)) if CELL_STROKE_TYPES.contains(&name.as_str()) => { + attrs.kind = Some(serde_json::from_value(json!({"kind": name})).map_err(|e| bad(cmd, e.to_string()))?); + } + Some(t @ Value::Object(_)) => { + let kind: StrokeType = serde_json::from_value(t.clone()).map_err(|e| bad(cmd, format!("bad stroke type: {e}")))?; + let finite = |v: &[f64]| v.len() <= 64 && v.iter().all(|x| x.is_finite() && *x >= 0.0); + match &kind { + StrokeType::Dashed { pattern } if !StrokeType::valid_dash_pattern(pattern) => { + return Err(bad(cmd, "a dash pattern needs 1 to 64 lengths, each 0 or 0.1 to 10000 pt, not all 0")); + } + StrokeType::Stripes { bands } if bands.len() > 64 || !bands.iter().all(|(a, b)| finite(&[*a, *b])) => { + return Err(bad(cmd, "stripes need up to 64 bands of non-negative numbers")); + } + StrokeType::Style { name } if !doc.stroke_styles.iter().any(|s| s.name == *name) => { + return Err(bad(cmd, format!("no stroke style `{name}`"))); + } + _ => {} + } + attrs.kind = Some(kind); + } + Some(other) => return Err(bad(cmd, format!("unknown stroke type {other}"))), + } + Ok(StrokeEdit { attrs, scaled_dashes }) + } + + pub(super) fn apply(&self, mut base: CellStroke) -> CellStroke { + self.attrs.apply_to(&mut base); + if self.scaled_dashes { + let w = base.weight.max(1.0); + base.kind = StrokeType::Dashed { pattern: vec![w * 3.0, w * 2.0] }; + } + base + } +} + +/// The stroke drawn on side `side` of the owner cell at `(r, c)` (where that side meets its +/// first neighbour), as rendering resolves it; the cell's own copy if the side has no stretch. +fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side: usize) -> Option { + let drawn = compose::table::cell_side_strokes(t, owners, (r, c), side).first().map(|s| s.stroke.clone()); + drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned())) +} + +/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt). +pub(super) fn first_baseline_params(p: &Value, id: &str) -> Result<(Option, Option)> { + let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?), + None => None, + }; + let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) { + Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) { + Some(m) => Some(m), + None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")), + }, + None => None, + }; + Ok((fb, min)) +} + +pub(super) fn set_cell(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.setCell"; + let g = target(s, p, ID)?; + let doc = &s.doc()?.doc; + let fill = swatch_in(doc, p, &["fill"], ID)?; + let tint = num_in(p, "tint", 0.0, 1.0, ID)?.map(|t| t as f32); + let insets: Option<[f64; 4]> = match p.get("insets") { + Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]), + Some(Value::Array(a)) if a.len() == 4 => { + let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); + Some([v[0], v[1], v[2], v[3]]) + } + _ => None, + }; + let vj = match str_param(p, "vj").or_else(|| str_param(p, "verticalJustification")) { + Some("top") => Some(VerticalJustification::Top), + Some("center") => Some(VerticalJustification::Center), + Some("bottom") => Some(VerticalJustification::Bottom), + Some("justify") => Some(VerticalJustification::Justify), + Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), + None => None, + }; + let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; + // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is + // composed so far, which the IDML import reports (#1468). + let rotation = match p.get("rotation").filter(|v| !v.is_null()) { + Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) { + Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d), + _ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")), + }, + None => None, + }; + let text = str_param(p, "text").map(|_| crate::cmd::text_param(p, "text")); + let stroke = match p.get("stroke").filter(|v| !v.is_null()) { + Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), + None => None, + }; + edit_table(s, &g, ID, |t| { + let owners = t.owners(); + let nc = t.ncols(); + let nr = t.nrows(); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + // Local edits outrank previously imported edges, including explicit zero/None. + let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; + let rg = g.range; + // Edges are inner or outer within the selection grown to whole merged cells, so a + // merged cell selected through one of its grid positions keeps its own outline. + let outline = t.expand_range(rg); + // Each chosen edge starts from the stroke drawn there, which both copies of a shared + // edge then take, so the edit changes only the attributes it names. + let mut bases = std::collections::HashMap::new(); + if let Some((_, edges)) = &stroke { + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell(r, c) else { continue }; + for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) { + bases.insert((r, c, i), base); + } + } + } + } + let mut edited = std::collections::HashSet::new(); + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell_mut(r, c) else { continue }; + if let Some(f) = &fill { + cell.fill = f.clone(); + } + if let Some(ti) = tint { + cell.fill_tint = ti; + } + if let Some(i) = insets { + cell.insets = i; + } + if let Some(v) = vj { + cell.vj = v; + } + if let Some(v) = first_baseline { + cell.first_baseline = v; + } + if let Some(v) = first_baseline_min { + cell.first_baseline_min = v; + } + if let Some(v) = rotation { + cell.rotation = v; + } + if let Some(tx) = &text { + let len = cell.text.len(); + cell.text.replace(0..len, tx); + } + if let Some((edit, _)) = &stroke { + for i in 0..4 { + if let Some(base) = bases.remove(&(r, c, i)) { + edited.insert((r, c, i)); + cell.strokes[i] = edit.apply(base); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; + } + } + } + } + // Keep both copies of unmerged shared boundaries consistent: each edited edge is + // copied to its neighbour. Left/right name physical sides, also in RTL tables. + let mirror = |t: &mut Table, from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize| { + if edited.contains(&(from.0, from.1, edge)) { + mirror_cell_edge(t, &owners, from, edge, to, opposite); + } + }; + if let Some((_, edges)) = &stroke { + if edges.top + && let Some(row) = rg.r0.checked_sub(1) + { + for c in rg.c0..=rg.c1 { + mirror(t, (rg.r0, c), 0, (row, c), 2); + } + } + if edges.bottom && rg.r1.saturating_add(1) < nr { + for c in rg.c0..=rg.c1 { + mirror(t, (rg.r1, c), 2, (rg.r1 + 1, c), 0); + } + } + if (if rtl { edges.right } else { edges.left }) + && let Some(col) = rg.c0.checked_sub(1) + { + for r in rg.r0..=rg.r1 { + mirror(t, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); + } + } + if (if rtl { edges.left } else { edges.right }) && rg.c1.saturating_add(1) < nc { + for r in rg.r0..=rg.r1 { + mirror(t, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); + } + } + } + ok() + }) +} + +/// The shown stroke attributes of the chosen edges of the target cells, and their fill; values +/// that differ are null. +pub(super) fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.getCellStroke"; + let g = target(s, p, ID)?; + let edges = EdgeSel::parse(p.get("edges"), ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let owners = t.owners(); + let outline = t.expand_range(g.range); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + // One value per key, or null once two edges (cells) disagree. + fn merge(slot: &mut Option, v: Value) { + match slot { + None => *slot = Some(v), + Some(cur) if *cur != v => *cur = Value::Null, + _ => {} + } + } + let keys = ["weight", "color", "tint", "type", "gapColor", "gapTint"]; + let mut stroke: [Option; 6] = Default::default(); + let (mut fill, mut fill_tint) = (None, None); + // Each drawn edge once: a shared edge is a side of both its cells. + let mut seen = std::collections::HashSet::new(); + for (r, c) in t.selected_owners(g.range) { + let Some(cell) = t.cell(r, c) else { continue }; + merge(&mut fill, json!(cell.fill)); + merge(&mut fill_tint, json!(cell.fill_tint)); + for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + if !on { + continue; + } + for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) { + let (x, y) = (Some((r, c)), side.neighbor); + if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) { + continue; + } + let e = side.stroke; + let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; + for (slot, v) in stroke.iter_mut().zip(values) { + merge(slot, v); + } + } + } + } + let mut out = serde_json::Map::new(); + for (k, v) in keys.iter().zip(stroke) { + out.insert((*k).into(), v.unwrap_or(Value::Null)); + } + out.insert("fill".into(), fill.unwrap_or(Value::Null)); + out.insert("fillTint".into(), fill_tint.unwrap_or(Value::Null)); + out.insert("edges".into(), json!(seen.len())); + Ok(Value::Object(out)) +} + +/// Only saturation needs rebasing. Preserve every distinct priority's order and keep +/// zero/negative priorities untouched; the work is bounded by the table's edges. +fn next_cell_stroke_priority(t: &mut Table) -> i32 { + let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0); + if highest < i32::MAX { + return highest + 1; + } + let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect(); + let mut ranks = std::collections::BTreeMap::new(); + let mut highest = 0i32; + for value in values { + highest = highest.saturating_add(1); + ranks.insert(value, highest); + } + for cell in &mut t.cells { + for priority in &mut cell.stroke_priorities { + if let Some(rank) = ranks.get(priority) { + *priority = *rank; + } + } + } + highest.saturating_add(1) +} + +fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) { + let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied(); + if owner(from) != Some(from) || owner(to) != Some(to) { + return; + } + // A merged cell's edge may border several separately editable cells. Mirroring + // a segment onto that whole edge would change unselected neighbors; priorities + // instead resolve each segment without altering the merged cell's formatting. + let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1; + if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) { + return; + } + let source = t + .cell(from.0, from.1) + .map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge])); + if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) { + neighbor.strokes[opposite] = stroke; + neighbor.stroke_defined[opposite] = defined; + neighbor.stroke_priorities[opposite] = priority; + neighbor.border_overrides[opposite] = border_override; + } +} + +#[cfg(test)] +mod arabic_tests { + use super::*; + #[test] + fn arabic_table_direction_and_physical_left_insertion_support_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 400, 300], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": "Right"})).unwrap(); + s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); + s.execute("table.select", &json!({"rows": [0, 0], "cols": [0, 0]})).unwrap(); + s.execute("table.insertColumnLeft", &json!({})).unwrap(); + let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + assert_eq!(t.ncols(), 3); + assert_eq!(t.cell(0, 0).unwrap().text.text, "Right"); + assert!(t.cell(0, 1).unwrap().text.text.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + assert_eq!(t.ncols(), 2); + assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft); + } + + /// A text frame whose paragraphs have a paragraph style with `direction`, with the story's + /// text selected. Returns the session and the story id. + fn styled_frame(direction: &str, text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + (s, r["story"].as_u64().unwrap()) + } + + fn only_table(s: &Session) -> std::sync::Arc { + s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone() + } + + /// Text converted to a table in a right-to-left paragraph laid its columns out left to right + /// (#100): the first column belongs on the right. + #[test] + fn tables_take_the_direction_of_their_paragraph() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, RightToLeft); + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None); + let placed = &cs.frames[0].tables[0]; + let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect); + assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}"); + + let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, LeftToRight); + + for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let (mut s, _) = styled_frame(direction, "x"); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph"); + } + } +} diff --git a/crates/engine/src/cmd/table/content.rs b/crates/engine/src/cmd/table/content.rs new file mode 100644 index 0000000000..aef5527327 --- /dev/null +++ b/crates/engine/src/cmd/table/content.rs @@ -0,0 +1,388 @@ +use super::selection::cell_at; +use super::target::{clamp_selection, edit_table, target}; +use super::*; + +pub(super) fn drop_cells(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.dropCells"; + let frame = crate::cmd::id_param(p, "frame").ok_or_else(|| bad(ID, "`frame` required"))?; + let pt = |k: &str| -> Result { + let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(ID, format!("`{k}` required")))?; + Ok(designcraft_geom::Point::new(a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0))) + }; + let (from, to) = (pt("from")?, pt("to")?); + let sel = s.doc()?.selection.cells; + let hit_from = cell_at(s, frame, from); + let hit_to = cell_at(s, frame, to); + if let (Some(ts), Some((sid, tid, fr, fc)), Some((_, tid2, tr, tc))) = (sel, hit_from, hit_to) + && ts.story == sid + && ts.table == tid + && tid2 == tid + && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) + { + let (nr, nc) = { + let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; + (t.nrows(), t.ncols()) + }; + let r = ts.range; + let rows = r.c0 == 0 && r.c1 + 1 == nc; + let cols = r.r0 == 0 && r.r1 + 1 == nr; + if rows || cols { + let (lo, hi, target) = if rows { (r.r0, r.r1, tr) } else { (r.c0, r.c1, tc) }; + if target >= lo && target <= hi { + return Ok(json!({"moved": 0})); + } + let n = hi - lo + 1; + let r2 = s.edit(|d, selm| { + let t = d.story_mut(sid).and_then(|st| st.table_mut(tid)).ok_or_else(|| bad(ID, "no table"))?; + let mv = |t: &mut designcraft_doc::Table, a: usize, b: usize| if rows { t.move_row(a, b) } else { t.move_col(a, b) }; + let new_lo = if target > hi { + for _ in 0..n { + mv(t, lo, target).map_err(|e| bad(ID, e))?; + } + target + 1 - n + } else { + for k in 0..n { + mv(t, lo + k, target + k).map_err(|e| bad(ID, e))?; + } + target + }; + let range = + if rows { CellRange::new(new_lo, r.c0, new_lo + n - 1, r.c1) } else { CellRange::new(r.r0, new_lo, r.r1, new_lo + n - 1) }; + selm.cells = Some(TableSel { range, ..ts }); + Ok(json!({"moved": n, "to": new_lo})) + })?; + return Ok(r2); + } + } + // Not a row/column drag: select text from the press to the release. + s.execute("text.placeCaret", &json!({"frame": frame.0, "point": [from.x, from.y]}))?; + s.execute("text.extendTo", &json!({"frame": frame.0, "point": [to.x, to.y]})) +} + +pub(super) fn place_graphic(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.placeGraphic"; + let g = target(s, p, ID)?; + let (bytes, name, link) = crate::cmd::file::read_source(p)?; + let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; + let (nw, nh) = designcraft_render::natural_size(&bytes, 0).ok_or_else(|| bad(ID, "invalid image dimensions"))?; + let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); + // Compute fitting before the edit: a small EPS can overflow in a huge cell. + let t = s.doc()?.doc.story(g.story).and_then(|st| st.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let (r, c) = t.owner(g.range.r0, g.range.c0); + let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?; + let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize); + let w: f64 = t.columns.get(c..c.saturating_add(cs).min(t.ncols())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.width).sum(); + let h: f64 = t.rows.get(r..r.saturating_add(rs).min(t.nrows())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.height).sum(); + let ins = cell.insets; + let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0)); + let k = if fill { (bw / nw).max(bh / nh) } else { (bw / nw).min(bh / nh) }; + let xf = designcraft_geom::Affine::translate(((bw - nw * k) / 2.0, (bh - nh * k) / 2.0)) * designcraft_geom::Affine::scale(k); + crate::cmd::file::validate_placement((nw, nh), designcraft_geom::Rect::new(0.0, 0.0, bw, bh), xf, ID)?; + s.edit(|d, sel| { + let fresh = designcraft_doc::AssetId(d.alloc()); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let aid = d.add_asset(designcraft_doc::Asset { + id: fresh, + name, + mime, + link, + data: std::sync::Arc::new(bytes), + pixels: Some((pw, ph)), + ..Default::default() + }); + let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; + let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; + let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; + cell.graphic = Some(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (nw, nh), + xf, + clip: None, + auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + clamp_selection(d, sel); + Ok(json!({"asset": aid.0, "row": r, "col": c})) + }) +} + +pub(super) fn sort_rows(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.sortRows")?; + let col = p.get("column").and_then(Value::as_u64).map_or(g.range.c0, |c| c as usize); + let desc = p.get("descending").and_then(Value::as_bool).unwrap_or(false); + edit_table(s, &g, "table.sortRows", |t| { + let (nr, nc) = (t.nrows(), t.ncols()); + if col >= nc { + return Err(bad("table.sortRows", format!("no column {col}"))); + } + if t.cells.iter().any(|c| c.row_span > 1) { + return Err(bad("table.sortRows", "unmerge cells that span rows first")); + } + let (h, f) = (t.header_rows(), t.footer_rows()); + let body: Vec = (h..nr - f).collect(); + let key = |r: usize| t.cell(r, col).map(|c| c.text.text.trim().to_string()).unwrap_or_default(); + let mut order = body.clone(); + order.sort_by(|a, b| { + let (ka, kb) = (key(*a), key(*b)); + let o = match (ka.replace(',', "").parse::(), kb.replace(',', "").parse::()) { + (Ok(x), Ok(y)) => x.total_cmp(&y), + _ => ka.to_lowercase().cmp(&kb.to_lowercase()), + }; + if desc { o.reverse() } else { o } + }); + let rows = t.rows.clone(); + let cells = t.cells.clone(); + for (k, src) in order.iter().enumerate() { + let dst = body[k]; + t.rows[dst] = rows[*src].clone(); + for c in 0..nc { + t.cells[dst * nc + c] = cells[src * nc + c].clone(); + } + } + Ok(json!({"sorted": body.len()})) + }) +} + +#[cfg(test)] +mod sort_tests { + use serde_json::json; + + use crate::Session; + + fn distribution_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] { + s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); + s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap(); + } + s + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + #[test] + fn distribute_selected_rows_preserves_height_content_and_undo() { + let mut s = distribution_session(); + s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap(); + let before = table(&s); + let selection = s.doc().unwrap().selection.cells; + let result = s.execute("table.distributeRows", &json!({})).unwrap(); + assert_eq!(result, json!({"rows": 2, "height": 60.0})); + let after = table(&s); + assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]); + assert_eq!(after.cells, before.cells); + assert_eq!(after.rows[0].kind, before.rows[0].kind); + assert_eq!(s.doc().unwrap().selection.cells, selection); + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + let heights: Vec<_> = + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect(); + assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(table(&s), after); + } + + #[test] + fn distribution_uses_content_grown_heights_and_explicit_table() { + let mut s = distribution_session(); + s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap(); + s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap(); + let st = s.doc().unwrap(); + let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let (sid, tid) = (story.id, *story.tables.keys().next().unwrap()); + let cs = s.cache.get(&st.doc, sid, None); + let total: f64 = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .flat_map(|f| &f.cells) + .filter(|c| c.col == 0 && (1..=2).contains(&c.row)) + .map(|c| c.rect.height()) + .sum(); + let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap(); + assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6); + assert!(table(&s).rows[1].height > 3.0); + assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly); + } + + #[test] + fn distribution_keeps_column_spans_and_caret() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + let before = table(&s); + let caret = s.doc().unwrap().selection.text; + let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap(); + assert_eq!(result["height"], 60.0); + assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2); + assert_eq!(table(&s).cells, before.cells); + assert_eq!(s.doc().unwrap().selection.text, caret); + } + + #[test] + fn distribution_rejects_merges_and_missing_rows_without_changes() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err()); + assert_eq!(table(&s), before); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err()); + assert_eq!(table(&s), before); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err()); + for params in + [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])] + { + assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}"); + assert_eq!(table(&s), before); + } + } + + #[test] + fn sort_body_rows_by_a_column() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + let cells = [("Name", "Qty"), ("pear", "10"), ("Apple", "9"), ("fig", "100")]; + for (r, (a, b)) in cells.iter().enumerate() { + s.execute("table.setCell", &json!({"rows": [r, r], "cols": [0, 0], "text": a})).unwrap(); + s.execute("table.setCell", &json!({"rows": [r, r], "cols": [1, 1], "text": b})).unwrap(); + } + let col = |s: &Session, c: usize| -> Vec { + let d = &s.doc().unwrap().doc; + let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + (0..t.nrows()).map(|r| t.cell(r, c).unwrap().text.text.clone()).collect() + }; + s.execute("table.sortRows", &json!({"column": 0})).unwrap(); + assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive"); + s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap(); + assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value"); + s.execute("table.splitVertically", &json!({"rows": [1, 1], "cols": [0, 0]})).unwrap(); + let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3))); + } + + #[test] + fn graphic_cells_draw_their_image() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.rowHeight", &json!({"height": 100})).ok(); + // A red image into the first cell. + let px: Vec = (0..40 * 40).flat_map(|_| [255, 0, 0, 255]).collect(); + let png = designcraft_render::Rendered { width: 40, height: 40, pixels: px }.to_png(); + s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": ""})).ok(); + let g = s.execute("table.placeGraphic", &json!({"base64": crate::cmd::file::base64_encode(&png), "name": "r.png", "fit": "fill"})).unwrap(); + assert_eq!((g["row"].as_u64(), g["col"].as_u64()), (Some(0), Some(0))); + let d = s.doc().unwrap().doc.clone(); + let st = d.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let t = st.tables.values().next().unwrap(); + let cs = s.cache.get(&d, st.id, None); + let cell = cs.frames[0].tables[0].cells.iter().find(|c| c.row == 0 && c.col == 0).unwrap().clone(); + let fx = d.item(cs.frames[0].frame).unwrap().xf; + let centre = fx * cell.clip.center(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let p = img.pixel(centre.x as u32, centre.y as u32); + assert!(p[0] > 200 && p[1] < 60, "red in the graphic cell: {p:?}"); + assert!(t.cell(0, 0).unwrap().graphic.is_some()); + s.execute("table.textCell", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.stories.values().flat_map(|st| st.tables.values()).all(|t| t.cell(0, 0).unwrap().graphic.is_none())); + } + + #[test] + fn move_rows_and_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap(); + for (r, c, t) in [(0, 0, "a"), (1, 0, "b"), (2, 0, "c"), (0, 1, "x")] { + s.execute("table.setCell", &json!({"rows": [r, r], "cols": [c, c], "text": t})).unwrap(); + } + let cell = |s: &Session, r: usize, c: usize| { + let d = &s.doc().unwrap().doc; + let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + t.cell(r, c).unwrap().text.text.clone() + }; + // Dragging a selected row onto the last row moves it there. + let d = s.doc().unwrap().doc.clone(); + let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); + let fid = d.stories[&sid].frames[0]; + let cs = s.cache.get(&d, sid, None); + let cells = &cs.frames[0].tables[0].cells; + let centre = |r: usize| { + let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); + d.text_xf(d.item(fid).unwrap()) * c + }; + let (from, to) = (centre(0), centre(2)); + s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); + let r2 = s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); + assert_eq!(r2["moved"], 1); + assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["a", "b", "c"]); + s.execute("table.moveRow", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); + s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); + assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); + } + + #[test] + fn move_row_cannot_move_a_header_row_out_of_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + // Dragging the selected header row onto the last body row is refused too. + let d = s.doc().unwrap().doc.clone(); + let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); + let fid = d.stories[&sid].frames[0]; + let cs = s.cache.get(&d, sid, None); + let cells = &cs.frames[0].tables[0].cells; + let centre = |r: usize| { + let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); + d.text_xf(d.item(fid).unwrap()) * c + }; + let (from, to) = (centre(0), centre(2)); + s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); + assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn table_options_limits_header_and_footer_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + // The limit `table.insert` has: the table used to grow by as many rows as were asked for. + s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); + let t = table(&s); + assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); + } +} diff --git a/crates/engine/src/cmd/table/options.rs b/crates/engine/src/cmd/table/options.rs new file mode 100644 index 0000000000..5cf82d87db --- /dev/null +++ b/crates/engine/src/cmd/table/options.rs @@ -0,0 +1,88 @@ +use super::cell::StrokeEdit; +use super::target::{color_param, edit_table, target}; +use super::*; + +fn parse_alt(v: &Value) -> Option { + if v.is_null() { + return None; + } + let d = AltFills::default(); + Some(AltFills { + first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)), + first_color: color_param(v, "firstColor").unwrap_or(d.first_color), + first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32), + next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)), + next_color: color_param(v, "nextColor").unwrap_or(d.next_color), + next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32), + skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), + skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), + }) +} + +pub(super) fn options(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.options")?; + let border = match p.get("border").filter(|v| !v.is_null()) { + Some(b) => Some(StrokeEdit::parse(&s.doc()?.doc, b, "table.options")?), + None => None, + }; + let p = p.clone(); + edit_table(s, &g, "table.options", |t| { + let o = &mut t.options; + if let Some(v) = p.get("direction") { + o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?; + } + if let Some(b) = &border { + o.border = b.apply(o.border.clone()); + o.borders = Default::default(); + } + o.space_before = f64_or(&p, "spaceBefore", o.space_before); + o.space_after = f64_or(&p, "spaceAfter", o.space_after); + if let Some(b) = p.get("repeatHeader").and_then(Value::as_bool) { + o.repeat_header = b; + } + if let Some(b) = p.get("repeatFooter").and_then(Value::as_bool) { + o.repeat_footer = b; + } + if let Some(v) = p.get("altRows") { + o.alt_rows = parse_alt(v); + } + if let Some(v) = p.get("altCols") { + o.alt_cols = parse_alt(v); + } + // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. + let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + if h.is_some() || f.is_some() { + let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); + // Add rows when there aren't enough for the requested header/footer plus one body row. + let need = h + f + 1; + if t.nrows() < need { + let at = t.nrows(); + t.insert_rows(at, need - at); + } + t.set_header_footer(h, f); + } + Ok(json!({"options": t.options})) + }) +} + +pub(super) fn get(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.get")?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?; + let owners = t.owners(); + let cells: Vec = (0..t.nrows()) + .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) + .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) + .filter_map(|(r, c)| { + let cell = t.cell(r, c)?; + Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, + "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) + }) + .collect(); + Ok(json!({ + "story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)), + "rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(), + "cells": cells, "options": t.options, "range": g.range, "style": t.style, + })) +} diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs new file mode 100644 index 0000000000..af2708e233 --- /dev/null +++ b/crates/engine/src/cmd/table/registry.rs @@ -0,0 +1,402 @@ +use super::cell::{get_cell_stroke, set_cell}; +use super::content::{drop_cells, place_graphic, sort_rows}; +use super::options::{get, options}; +use super::selection::{move_rc, select, step_cell}; +use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; +use super::structure::{ + can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, + merge, split, unmerge, +}; +use super::styles::{cell_style_apply, cell_style_create, cell_style_edit, table_style_apply, table_style_create, table_style_edit}; +use super::target::{edit_table, in_cell, in_table, in_table_or_ids, target}; +use super::*; + +pub fn specs() -> Vec { + let mut v = style_specs(); + v.extend(table_specs()); + v +} + +fn style_specs() -> Vec { + vec![ + cmd!( + "style.cell.create", + "New Cell Style…", + [], + None, + "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", + has_doc, + |s, p| cell_style_create(s, p) + ), + cmd!( + "style.cell.apply", + "Apply Cell Style", + [], + None, + "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — the target cells (the whole table when the cursor is in it)", + in_table, + cell_style_apply + ), + cmd!( + "style.cell.edit", + "Cell Style Options…", + [], + None, + "{name, …same as style.cell.create} — cells using the style follow", + has_doc, + cell_style_edit + ), + cmd!( + "style.table.create", + "New Table Style…", + [], + None, + "{name, basedOn?: table style name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", + has_doc, + table_style_create + ), + cmd!( + "style.table.apply", + "Apply Table Style", + [], + None, + "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?}", + in_table, + table_style_apply + ), + cmd!( + "style.table.edit", + "Table Style Options…", + [], + None, + "{name, …same as style.table.create} — tables using the style follow", + has_doc, + table_style_edit + ), + ] +} + +fn table_specs() -> Vec { + vec![ + cmd!( + "table.insert", + "Create Table", + [], + Some("Cmd+Alt+Shift+T"), + "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", + has_text, + insert + ), + cmd!( + "table.convertFromText", + "Convert Text to Table", + ["Table"], + None, + "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", + has_text, + convert_from_text + ), + cmd!( + "table.convertToText", + "Convert Table to Text", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", + in_table, + convert_to_text + ), + cmd!( + "table.insertRow", + "Insert Row", + [], + None, + "{where?: above|below, count? (1), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_row(s, p, None) + ), + cmd!( + "table.insertRowAbove", + "Insert Row Above", + ["Table", "Insert"], + None, + "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_row(s, p, Some(true)) + ), + cmd!( + "table.insertRowBelow", + "Insert Row Below", + ["Table", "Insert"], + None, + "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_row(s, p, Some(false)) + ), + cmd!( + "table.insertColumn", + "Insert Column", + [], + None, + "{where?: left|right, count? (1), width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_col(s, p, None) + ), + cmd!( + "table.insertColumnLeft", + "Insert Column Left", + ["Table", "Insert"], + None, + "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_col(s, p, Some(true)) + ), + cmd!( + "table.insertColumnRight", + "Insert Column Right", + ["Table", "Insert"], + None, + "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_col(s, p, Some(false)) + ), + cmd!( + "table.deleteRow", + "Delete Row", + ["Table", "Delete"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the rows of the target cells", + in_table, + delete_rows + ), + cmd!( + "table.deleteColumn", + "Delete Column", + ["Table", "Delete"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the columns of the target cells", + in_table, + delete_cols + ), + cmd!( + "table.delete", + "Delete Table", + ["Table", "Delete"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + delete_table + ), + cmd!( + "table.merge", + "Merge Cells", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — merge the target cell range", + in_table, + merge + ), + cmd!( + "table.dropCells", + "Drag Rows or Columns", + [], + None, + "{frame, from: [x, y], to: [x, y]} — selected whole rows (or columns) dragged from `from` move to the row (column) at `to`; otherwise a text drag", + has_doc, + drop_cells + ), + cmd!( + "table.placeGraphic", + "Convert Cell to Graphic Cell", + ["Table", "Convert Cell Type"], + None, + "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", + in_table, + place_graphic + ), + cmd!( + "table.textCell", + "Convert Cell to Text Cell", + ["Table", "Convert Cell Type"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", + in_table, + |s, p| { + let g = target(s, p, "table.textCell")?; + edit_table(s, &g, "table.textCell", |t| { + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cell.graphic = None; + } + } + ok() + }) + } + ), + cmd!( + "table.moveRow", + "Move Row", + [], + None, + "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target row) — like dragging a row", + in_table, + |s, p| move_rc(s, p, true) + ), + cmd!( + "table.moveColumn", + "Move Column", + [], + None, + "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target column)", + in_table, + |s, p| move_rc(s, p, false) + ), + cmd!( + "table.sortRows", + "Sort", + ["Table"], + None, + "{column?: 0-based (default: the target cell's), descending?: bool, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — body rows by that column's text (numbers numerically)", + in_table, + sort_rows + ), + cmd!( + "table.unmerge", + "Unmerge Cells", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + unmerge + ), + cmd!( + "table.splitHorizontally", + "Split Cell Horizontally", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, one above the other", + in_table, + |s, p| split(s, p, true) + ), + cmd!( + "table.splitVertically", + "Split Cell Vertically", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, side by side", + in_table, + |s, p| split(s, p, false) + ), + cmd!( + "table.setCell", + "Cell Options", + [], + None, + "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", + in_table, + set_cell + ), + cmd!( + query "table.getCellStroke", + "Get Cell Stroke", + [], + None, + "{story?, table?, rows?, cols?, edges?} → {weight, color, tint, type, gapColor, gapTint, fill, fillTint, edges: count} of the chosen edges of the target cells; a value that differs between them is null", + in_table_or_ids, + get_cell_stroke + ), + cmd!( + "table.setRowHeight", + "Row Height", + [], + None, + "{height, mode?: atLeast|exactly, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + set_row_height + ), + cmd!( + "table.setColumnWidth", + "Column Width", + [], + None, + "{width, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + set_col_width + ), + cmd!( + "table.resizeBorder", + "Resize Row or Column", + [], + None, + "{story?, table?, frame?, axis: column|row, index, delta? | size?, keepTableSize?: bool} — moves the border after column/row `index` by `delta` (or makes that column/row `size` wide/high); later columns/rows move along. The last index is the table's end/bottom edge. keepTableSize: on an inner border the next column/row gives or takes the change; on the edge all columns/rows scale proportionally. A dragged row becomes At Least. Refuses a locked `frame`", + in_table_or_ids, + resize_border + ), + cmd!( + "table.distributeColumns", + "Distribute Columns Evenly", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + distribute_cols + ), + cmd!( + "table.distributeRows", + "Distribute Rows Evenly", + ["Table"], + None, + "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first", + in_table, + distribute_rows + ), + cmd!( + "table.options", + "Table Options", + [], + None, + "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + options + ), + cmd!( + "table.convertRowsToHeader", + "To Header", + ["Table", "Convert Rows"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the top of the table or right after its header rows", + |s| can_convert_rows(s, RowKind::Header), + |s, p| convert_rows(s, p, RowKind::Header) + ), + cmd!( + "table.convertRowsToBody", + "To Body", + ["Table", "Convert Rows"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target header rows next to the body, footer rows next to it, or both", + |s| can_convert_rows(s, RowKind::Body), + |s, p| convert_rows(s, p, RowKind::Body) + ), + cmd!( + "table.convertRowsToFooter", + "To Footer", + ["Table", "Convert Rows"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the bottom of the table or right before its footer rows", + |s| can_convert_rows(s, RowKind::Footer), + |s, p| convert_rows(s, p, RowKind::Footer) + ), + cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, what?: cell|row|column|table}", in_table_or_ids, select), + cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { + select(s, &crate::cmd::with_param(p, "what", json!("table"))) + }), + cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { + select(s, &crate::cmd::with_param(p, "what", json!("row"))) + }), + cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { + select(s, &crate::cmd::with_param(p, "what", json!("column"))) + }), + cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), + cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), + cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), + ] +} diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs new file mode 100644 index 0000000000..2fab01e9f5 --- /dev/null +++ b/crates/engine/src/cmd/table/selection.rs @@ -0,0 +1,91 @@ +use super::target::{Tgt, edit_table, target}; +use super::*; + +// ---------- selection ---------- + +pub(super) fn select(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.select")?; + let (nr, nc) = { + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.select", "no table"))?; + (t.nrows(), t.ncols()) + }; + let mut r = g.range; + match str_param(p, "what") { + Some("table") => r = CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }, + Some("row") => { + r.c0 = 0; + r.c1 = nc - 1; + } + Some("column") => { + r.r0 = 0; + r.r1 = nr - 1; + } + _ => {} + } + let st = s.doc_mut()?; + let frame = st.selection.text.and_then(|t| t.frame); + st.selection = Selection { + cells: Some(TableSel { story: g.story, table: g.table, range: r }), + text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), + ..Default::default() + }; + st.bump_revision(); + Ok(json!({"story": g.story.0, "table": g.table, "range": r})) +} + +/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last +/// cell adds a row. +pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { + let t = s.doc()?.selection.text.ok_or_else(|| bad("table.nextCell", "no caret"))?; + let c = t.cell.ok_or_else(|| bad("table.nextCell", "not in a cell"))?; + let (owners, nr, nc) = { + let st = s.doc()?; + let tb = st.doc.story(t.story).and_then(|x| x.tables.get(&c.table)).ok_or_else(|| bad("table.nextCell", "no table"))?; + (tb.owners(), tb.nrows(), tb.ncols()) + }; + let cur = c.row * nc + c.col; + let next = + if forward { (cur + 1..nr * nc).find(|&i| owners[i] == (i / nc, i % nc)) } else { (0..cur).rev().find(|&i| owners[i] == (i / nc, i % nc)) }; + let (row, col) = match next { + Some(i) => (i / nc, i % nc), + None if forward => { + let g = Tgt { story: t.story, table: c.table, range: CellRange::cell(nr - 1, 0) }; + edit_table(s, &g, "table.nextCell", |tb| { + tb.insert_rows(nr, 1); + ok() + })?; + (nr, 0) + } + None => return ok(), + }; + let st = s.doc_mut()?; + let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t }); + st.bump_revision(); + Ok(json!({"row": row, "col": col})) +} + +/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col). +pub(super) fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> { + let st = s.active()?; + let loc = st.doc.find(frame)?; + let it = st.doc.item_at(&loc)?; + let sid = it.text_frame()?.story; + let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt; + let cs = s.cache.get(&st.doc, sid, None); + let fi = cs.frames.iter().position(|f| f.frame == frame)?; + let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?; + Some((sid, table, row, col)) +} + +pub(super) fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { + const ID: &str = "table.move"; + let g = target(s, p, ID)?; + let from = p.get("from").and_then(Value::as_u64).map_or(if rows { g.range.r0 } else { g.range.c0 }, |v| v as usize); + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(ID, "`to` required"))? as usize; + edit_table(s, &g, ID, |t| { + if rows { t.move_row(from, to) } else { t.move_col(from, to) }.map_err(|e| bad(ID, e))?; + Ok(json!({"from": from, "to": to})) + }) +} diff --git a/crates/engine/src/cmd/table/sizing.rs b/crates/engine/src/cmd/table/sizing.rs new file mode 100644 index 0000000000..cd1d1af533 --- /dev/null +++ b/crates/engine/src/cmd/table/sizing.rs @@ -0,0 +1,322 @@ +use super::target::{edit_table, target}; +use super::*; + +pub(super) fn set_row_height(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.setRowHeight")?; + let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0)); + let mode = match str_param(p, "mode") { + Some("exactly") => Some(RowHeightMode::Exactly), + Some("atLeast") => Some(RowHeightMode::AtLeast), + Some(o) => return Err(bad("table.setRowHeight", format!("unknown mode `{o}`"))), + None => None, + }; + if h.is_none() && mode.is_none() { + return Err(bad("table.setRowHeight", "missing height")); + } + edit_table(s, &g, "table.setRowHeight", |t| { + for row in &mut t.rows[g.range.r0..=g.range.r1] { + if let Some(h) = h { + row.height = h; + } + if let Some(m) = mode { + row.mode = m; + } + } + ok() + }) +} + +pub(super) fn set_col_width(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.setColumnWidth")?; + let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad("table.setColumnWidth", "missing width"))?.clamp(3.0, 10000.0); + edit_table(s, &g, "table.setColumnWidth", |t| { + for c in &mut t.columns[g.range.c0..=g.range.c1] { + c.width = w; + } + ok() + }) +} + +/// Smallest column width and row height a border drag leaves. +const MIN_TRACK: f64 = 3.0; + +const MAX_TRACK: f64 = 10000.0; + +/// Composed height of `row` (laid out from cells that span only that row), else its model height. +fn composed_row_height(cs: &compose::ComposedStory, t: &Table, row: usize) -> Option { + let laid_out = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == t.id) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row && t.cell(c.row, c.col).is_some_and(|x| x.row_span <= 1)) + .map(|c| c.rect.height()) + .filter(|h| h.is_finite() && *h > 0.0) + .reduce(f64::max); + laid_out.or_else(|| t.rows.get(row).map(|r| r.height)) +} + +/// Move the border after `index` among `sizes` by `delta`. Returns the new sizes. +fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option> { + let mut out = sizes.to_vec(); + let cur = *sizes.get(index)?; + let last = index + 1 == sizes.len(); + if keep && last { + // Scale every track so the table edge moves by `delta`. + let total: f64 = sizes.iter().sum(); + let smallest = sizes.iter().copied().fold(f64::INFINITY, f64::min); + let largest = sizes.iter().copied().fold(0.0, f64::max); + if !(total > 0.0 && smallest > 0.0 && largest > 0.0) { + return None; + } + let lo = (MIN_TRACK / smallest).min(1.0); + let hi = (MAX_TRACK / largest).max(1.0); + let f = ((total + delta) / total).clamp(lo, hi); + out.iter_mut().for_each(|v| *v *= f); + } else if keep { + let next = *sizes.get(index + 1)?; + let pair = cur + next; + let a = (cur + delta).clamp(MIN_TRACK, (pair - MIN_TRACK).max(MIN_TRACK)); + let [this, after] = out.get_mut(index..index + 2)? else { return None }; + *this = a; + *after = (pair - a).max(MIN_TRACK); + } else { + *out.get_mut(index)? = (cur + delta).clamp(MIN_TRACK, MAX_TRACK); + } + out.iter().all(|v| v.is_finite()).then_some(out) +} + +/// The frame a border drag names must hold the table's story and be editable. +fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> { + const ID: &str = "table.resizeBorder"; + if let Some(fid) = crate::cmd::id_param(p, "frame") { + let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?; + if it.text_frame().map(|f| f.story) != Some(story) { + return Err(bad(ID, "the table is not in that frame's story")); + } + if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) { + return Err(bad(ID, "the frame or its layer is locked")); + } + } + Ok(()) +} + +/// The change in track size: `delta`, or `size` measured from the current size `cur`. +fn border_delta(p: &Value, cur: f64) -> Result { + const ID: &str = "table.resizeBorder"; + let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) { + (Some(d), _) => d, + (None, Some(sz)) => sz - cur, + (None, None) => return Err(bad(ID, "`delta` or `size` required")), + }; + if !delta.is_finite() { + return Err(bad(ID, "`delta`/`size` must be a finite number")); + } + Ok(delta) +} + +pub(super) fn resize_border(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.resizeBorder"; + let column = match str_param(p, "axis") { + Some("column") => true, + Some("row") => false, + _ => return Err(bad(ID, "axis must be `column` or `row`")), + }; + let index = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(ID, "`index` required"))?; + let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false); + let g = target(s, p, ID)?; + let st = s.doc()?; + check_border_frame(st, p, g.story)?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let sizes: Vec = if column { + t.columns.iter().map(|c| c.width).collect() + } else { + let cs = s.cache.get(&st.doc, g.story, None); + (0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect() + }; + let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?; + let delta = border_delta(p, cur)?; + let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?; + let size = new.get(index).copied().unwrap_or(cur); + let scaled = keep && index + 1 == sizes.len(); + edit_table(s, &g, ID, |t| { + if column { + for (c, w) in t.columns.iter_mut().zip(&new) { + c.width = *w; + } + } else { + for (i, (r, h)) in t.rows.iter_mut().zip(&new).enumerate() { + // Only the rows the drag changed take the new height. + if scaled || i == index || (keep && i == index + 1) { + r.height = *h; + } + if i == index && !scaled { + r.mode = RowHeightMode::AtLeast; + } + } + } + Ok(json!({"axis": if column { "column" } else { "row" }, "index": index, "size": size})) + }) +} + +pub(super) fn distribute_rows(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.distributeRows"; + if !p.is_object() { + return Err(bad(ID, "parameters must be an object")); + } + if let Some(rows) = p.get("rows") { + let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()))); + if !valid { + return Err(bad(ID, "rows must be two non-negative row indices")); + } + } + for key in ["row", "story", "table"] { + if let Some(v) = p.get(key) + && v.as_u64().is_none_or(|n| usize::try_from(n).is_err()) + { + return Err(bad(ID, format!("{key} must be a non-negative integer"))); + } + } + let g = target(s, p, ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + if t.cells.iter().any(|c| c.row_span > 1) { + return Err(bad(ID, "unmerge cells that span rows first")); + } + let cs = s.cache.get(&st.doc, g.story, None); + let mut total = 0.0; + for row in g.range.r0..=g.range.r1 { + let height = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == g.table) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row) + .map(|c| c.rect.height()) + .reduce(f64::max) + .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?; + if !height.is_finite() || height <= 0.0 { + return Err(bad(ID, "invalid composed row height")); + } + total += height; + } + let count = g.range.r1 - g.range.r0 + 1; + let height = total / count as f64; + if !height.is_finite() { + return Err(bad(ID, "invalid total row height")); + } + edit_table(s, &g, ID, |t| { + let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?; + for row in rows { + row.height = height; + row.mode = RowHeightMode::Exactly; + } + Ok(json!({"rows": count, "height": height})) + }) +} + +pub(super) fn distribute_cols(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.distributeColumns")?; + edit_table(s, &g, "table.distributeColumns", |t| { + let cols = &mut t.columns[g.range.c0..=g.range.c1]; + let avg = cols.iter().map(|c| c.width).sum::() / cols.len() as f64; + cols.iter_mut().for_each(|c| c.width = avg); + ok() + }) +} + +#[cfg(test)] +mod resize_border_tests { + use serde_json::json; + + use crate::Session; + + /// A 3×3 table of 80 pt columns; returns the session, frame id, story and table ids. + fn session() -> (Session, u64, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 600], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let t = s.execute("table.insert", &json!({"rows": 3, "cols": 3, "width": 240})).unwrap(); + (s, r["id"].as_u64().unwrap(), t["story"].as_u64().unwrap(), t["table"].as_u64().unwrap()) + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + fn widths(s: &Session) -> Vec { + table(s).columns.iter().map(|c| c.width).collect() + } + + /// Composed cell rects of column 0, top to bottom. + fn rows(s: &Session) -> Vec { + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|x| !x.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect).collect() + } + + fn resize(s: &mut Session, ids: (u64, u64, u64), mut p: serde_json::Value) { + p["frame"] = json!(ids.0); + p["story"] = json!(ids.1); + p["table"] = json!(ids.2); + s.execute("table.resizeBorder", &p).unwrap(); + } + + #[test] + fn column_border_drags_widen_the_table_or_keep_its_width() { + let (mut s, f, st, t) = session(); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": 20})); + assert_eq!(widths(&s), [100.0, 80.0, 80.0]); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 30, "keepTableSize": true})); + assert_eq!(widths(&s), [100.0, 110.0, 50.0]); + // Clamped at the minimum; the neighbour takes only what is left. + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 500, "keepTableSize": true})); + assert_eq!(widths(&s), [100.0, 157.0, 3.0]); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": -1000})); + assert_eq!(widths(&s), [3.0, 157.0, 3.0]); + // Shift on the end edge scales every column. + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 2, "size": 166, "keepTableSize": true})); + let w = widths(&s); + assert!((w.iter().sum::() - 326.0).abs() < 1e-9, "{w:?}"); + assert!((w[1] / w[0] - 157.0 / 3.0).abs() < 1e-9, "{w:?}"); + assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 3, "delta": 1})).is_err()); + assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 0})).is_err()); + } + + #[test] + fn row_drag_sets_at_least_and_moves_later_rows() { + let (mut s, f, st, t) = session(); + s.execute("table.setRowHeight", &json!({"table": t, "row": 0, "height": 30, "mode": "exactly"})).unwrap(); + let before = rows(&s); + resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": 12})); + let row = &table(&s).rows[0]; + assert_eq!((row.height, row.mode), (42.0, designcraft_doc::RowHeightMode::AtLeast)); + let after = rows(&s); + assert!((after[0].height() - 42.0).abs() < 1e-9); + assert!((after[1].y0 - before[1].y0 - 12.0).abs() < 1e-9 && (after[1].height() - before[1].height()).abs() < 1e-9); + // Below the minimum: clamped, never negative. + resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": -1000})); + assert_eq!(table(&s).rows[0].height, 3.0); + } + + #[test] + fn a_drag_is_one_undo_step_and_locked_frames_refuse() { + let (mut s, f, st, t) = session(); + let before = table(&s); + s.begin_interaction("Resize Column").unwrap(); + for d in [5.0, 15.0, 25.0] { + let p = json!({"frame": f, "story": st, "table": t, "axis": "column", "index": 0, "delta": d}); + s.preview("table.resizeBorder", p).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(widths(&s), [105.0, 80.0, 80.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("object.setFlags", &json!({"ids": [f], "locked": true})).unwrap(); + assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); + } +} diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs new file mode 100644 index 0000000000..65bcdb3d6a --- /dev/null +++ b/crates/engine/src/cmd/table/structure.rs @@ -0,0 +1,240 @@ +use super::target::{column_width, count, edit_table, in_cell, para_direction, target}; +use super::*; + +pub(super) fn insert(s: &mut Session, p: &Value) -> Result { + let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; + if t.cell.is_some() { + return Err(bad("table.insert", "tables can't be nested in cells")); + } + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; + let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; + let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); + s.edit(|d, sel| { + let id = d.alloc(); + let mut table = Table::new(id, rows, cols, header, footer, width); + table.options.direction = para_direction(d, t.story, t.range().start); + let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?; + // Cells start with the paragraph format at the caret. + let pf = st.paras[st.para_at(t.range().start)].clone(); + let cf = st.char_format_at(t.range().start).clone(); + for c in &mut table.cells { + c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; + c.text.chars[0].format = cf.clone(); + } + let r = t.range(); + st.delete(r.clone()); + st.insert_table(r.start, table); + *sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) }); + Ok(json!({"story": t.story.0, "table": id})) + }) +} + +pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { + let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; + if t.cell.is_some() { + return Err(bad("table.convertFromText", "select text outside tables")); + } + let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; + let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); + s.edit(|d, sel| { + let id = d.alloc(); + let direction = para_direction(d, t.story, t.range().start); + let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?; + let ranges = st.para_ranges(); + let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end)); + if (pa..=pb).any(|i| st.paras[i].table.is_some()) { + return Err(bad("table.convertFromText", "the selection contains a table")); + } + let (a, b) = (ranges[pa].start, ranges[pb].end); + let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); + let pf = st.paras[pa].clone(); + let cf = st.format_after(a).clone(); + let mut table = Table::from_strings(id, &data, width, &pf, &cf); + table.options.direction = direction; + st.delete(a..b); + let anchor = st.insert_table(a, table); + *sel = Selection::text(TextSel::caret(t.story, anchor)); + Ok(json!({"table": id, "rows": data.len()})) + }) +} + +pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.convertToText")?; + s.edit(|d, sel| { + let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; + let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); + let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; + st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); + *sel = Selection::text(TextSel::caret(g.story, a + text.len())); + ok() + }) +} + +pub(super) fn delete_table(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.delete")?; + s.edit(|d, sel| { + let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?; + let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?; + // Remove the anchor paragraph (with one separator) so no empty paragraph is left behind. + let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8(); + let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') { + // Inserted at the end of the story: remove the empty paragraph after it too. + a - 1..end + 1 + } else if st.text[end..].starts_with('\n') { + a..end + 1 + } else if a > 0 && st.text[..a].ends_with('\n') { + a - 1..end + } else { + a..end + }; + st.delete(r.clone()); + *sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len()))); + ok() + }) +} + +// ---------- rows and columns ---------- + +pub(super) fn insert_row(s: &mut Session, p: &Value, above: Option) -> Result { + let g = target(s, p, "table.insertRow")?; + let above = above.unwrap_or(str_param(p, "where") == Some("above")); + let n = count(p); + let at = if above { g.range.r0 } else { g.range.r1 + 1 }; + let r = edit_table(s, &g, "table.insertRow", |t| { + t.insert_rows(at, n); + Ok(json!({"rows": t.nrows()})) + })?; + shift_selection(s, g.table, at, n, true); + Ok(r) +} + +/// After inserting rows/columns at `at`, selections at or after it follow their cells. +fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool) { + let Ok(st) = s.doc_mut() else { return }; + if let Some(t) = st.selection.text.as_mut() + && let Some(c) = t.cell.as_mut() + && c.table == table + { + let v = if rows { &mut c.row } else { &mut c.col }; + if *v >= at { + *v += n; + } + } + if let Some(ts) = st.selection.cells.as_mut() + && ts.table == table + { + let (a, b) = if rows { (&mut ts.range.r0, &mut ts.range.r1) } else { (&mut ts.range.c0, &mut ts.range.c1) }; + if *a >= at { + *a += n; + } + if *b >= at { + *b += n; + } + } +} + +pub(super) fn insert_col(s: &mut Session, p: &Value, left: Option) -> Result { + let g = target(s, p, "table.insertColumn")?; + let left = left.unwrap_or(str_param(p, "where") == Some("left")); + let rtl = s + .doc()? + .doc + .story(g.story) + .and_then(|st| st.tables.get(&g.table)) + .is_some_and(|t| t.options.direction == designcraft_doc::TextDirection::RightToLeft); + let n = count(p); + let at = if left != rtl { g.range.c0 } else { g.range.c1 + 1 }; + let width = p.get("width").and_then(Value::as_f64); + let r = edit_table(s, &g, "table.insertColumn", |t| { + let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width); + t.insert_cols(at, n, w); + Ok(json!({"columns": t.ncols()})) + })?; + shift_selection(s, g.table, at, n, false); + Ok(r) +} + +pub(super) fn delete_rows(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.deleteRow")?; + let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.r0 == 0 && g.range.r1 + 1 >= t.nrows()); + if all { + return delete_table(s, p); + } + edit_table(s, &g, "table.deleteRow", |t| { + t.delete_rows(g.range.r0, g.range.r1 - g.range.r0 + 1); + Ok(json!({"rows": t.nrows()})) + }) +} + +pub(super) fn delete_cols(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.deleteColumn")?; + let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.c0 == 0 && g.range.c1 + 1 >= t.ncols()); + if all { + return delete_table(s, p); + } + edit_table(s, &g, "table.deleteColumn", |t| { + t.delete_cols(g.range.c0, g.range.c1 - g.range.c0 + 1); + Ok(json!({"columns": t.ncols()})) + }) +} + +pub(super) fn merge(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.merge")?; + let r = edit_table(s, &g, "table.merge", |t| { + t.merge(g.range).map_err(|e| bad("table.merge", e))?; + Ok(t.expand_range(g.range)) + })?; + // The caret goes to the merged cell. + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { + story: g.story, + anchor: 0, + focus: 0, + frame: st.selection.text.and_then(|t| t.frame), + cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }), + }); + ok() +} + +pub(super) fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { + let g = target(s, p, "table.split")?; + edit_table(s, &g, "table.split", |t| { + t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; + Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) + }) +} + +pub(super) fn unmerge(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.unmerge")?; + edit_table(s, &g, "table.unmerge", |t| { + for r in g.range.r0..=g.range.r1 { + for c in g.range.c0..=g.range.c1 { + t.unmerge(r, c); + } + } + ok() + }) +} + +/// Convert Rows is greyed out unless the selected cells (or the caret's cell) can change kind. +pub(super) fn can_convert_rows(s: &Session, kind: RowKind) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.selection.cells.is_none() { + in_cell(s)?; + } + let g = target(s, &Value::Null, "table.convertRows").map_err(|e| e.to_string())?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or("no table")?; + t.convert_rows_counts(g.range.r0, g.range.r1, kind).map(|_| ()) +} + +pub(super) fn convert_rows(s: &mut Session, p: &Value, kind: RowKind) -> Result { + const ID: &str = "table.convertRows"; + let g = target(s, p, ID)?; + edit_table(s, &g, ID, |t| { + let (h, f) = t.convert_rows_counts(g.range.r0, g.range.r1, kind).map_err(|e| bad(ID, e))?; + t.set_header_footer(h, f); + Ok(json!({"headerRows": h, "footerRows": f})) + }) +} diff --git a/crates/engine/src/cmd/table/styles.rs b/crates/engine/src/cmd/table/styles.rs new file mode 100644 index 0000000000..098ae406df --- /dev/null +++ b/crates/engine/src/cmd/table/styles.rs @@ -0,0 +1,350 @@ +use super::cell::{StrokeEdit, first_baseline_params}; +use super::target::{color_param, edit_table, target}; +use super::*; + +// ---------- cell and table styles ---------- + +/// A style's uniform edge stroke from `stroke`/`border`: the given values over the default +/// edge, validated like `table.setCell`'s stroke. +fn stroke_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result> { + match v.filter(|v| !v.is_null()) { + Some(v) => Ok(Some(StrokeEdit::parse(&s.doc()?.doc, v, cmd)?.apply(CellStroke::default()))), + None => Ok(None), + } +} + +/// Fill in the cell style fields given in `p`. +fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Option, cmd: &str) -> Result<()> { + if let Some(v) = p.get("basedOn") { + cs.based_on = v.as_str().map(str::to_string); + } + if let Some(f) = color_param(p, "fill") { + cs.fill = Some(f); + } + if let Some(t) = p.get("tint").and_then(Value::as_f64) { + cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32); + } + match p.get("insets") { + Some(Value::Number(n)) => { + cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]); + cs.inset_overrides = Default::default(); + } + Some(Value::Array(a)) if a.len() == 4 => { + let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); + cs.insets = Some([v[0], v[1], v[2], v[3]]); + cs.inset_overrides = Default::default(); + } + _ => {} + } + if let Some(v) = p.get("vj") { + cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); + } + let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?; + if first_baseline.is_some() { + cs.first_baseline = first_baseline; + } + if first_baseline_min.is_some() { + cs.first_baseline_min = first_baseline_min; + } + if let Some(st) = stroke { + cs.stroke = Some(st); + cs.strokes = Default::default(); + } + if let Some(ps) = str_param(p, "paragraphStyle") { + cs.paragraph_style = Some(ps.to_string()); + } + Ok(()) +} + +pub(super) fn cell_style_create(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Cell Style 1").to_string(); + let mut cs = designcraft_doc::CellStyle::default(); + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) { + let g = target(s, p, "style.cell.create")?; + let d = &s.doc()?.doc; + let cell = d + .story(g.story) + .and_then(|st| st.tables.get(&g.table)) + .and_then(|t| { + let (r, c) = t.owner(g.range.r0, g.range.c0); + t.cell(r, c) + }) + .ok_or_else(|| bad("style.cell.create", "no cell"))?; + cs.fill = Some(cell.fill.clone()); + cs.fill_tint = Some(cell.fill_tint); + cs.insets = Some(cell.insets); + cs.vj = Some(cell.vj); + cs.first_baseline = Some(cell.first_baseline); + cs.first_baseline_min = Some(cell.first_baseline_min); + cs.stroke = Some(cell.strokes[0].clone()); + cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); + } + let stroke = stroke_param(s, p.get("stroke"), "style.cell.create")?; + cell_style_fields(&mut cs, p, stroke, "style.cell.create")?; + s.edit(|d, _| { + let name = designcraft_doc::Styles::unique_name(|n| d.styles.cell.iter().any(|c| c.name == n), &base); + cs.name = name.clone(); + d.styles_mut().cell.push(cs); + Ok(json!({"name": name})) + }) +} + +pub(super) fn cell_style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string(); + let styles = &s.doc()?.doc.styles.cell; + if !styles.iter().any(|c| c.name == name) { + return Err(bad("style.cell.apply", format!("no cell style `{name}`"))); + } + let cs = designcraft_doc::resolve_cell_style(styles, &name); + let g = target(s, p, "style.cell.apply")?; + edit_table(s, &g, "style.cell.apply", |t| { + let mut n = 0; + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cs.apply_to(cell); + n += 1; + } + } + Ok(json!({"cells": n})) + }) +} + +/// Re-apply cell styles named in `names` to every cell using them (after a style edit). +fn reapply_cell_styles(d: &mut Document, names: &[String]) { + let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect(); + for sid in d.stories.keys().copied().collect::>() { + let Some(st) = d.story_mut(sid) else { continue }; + for t in st.tables.values_mut() { + if !t.cells.iter().any(|c| names.contains(&c.style)) { + continue; + } + for cell in &mut std::sync::Arc::make_mut(t).cells { + if names.contains(&cell.style) + && let Some(cs) = styles.iter().find(|x| x.name == cell.style) + { + cs.apply_to(cell); + } + } + } + st.rev = st.rev.wrapping_add(1); + } +} + +/// Include descendants when editing an inherited style, with the resolver's depth limit. +fn dependent_styles<'a>(name: &str, styles: impl Iterator)>) -> Vec { + let styles: Vec<_> = styles.collect(); + styles + .iter() + .filter(|(candidate, _)| { + let mut current = Some(*candidate); + for _ in 0..32 { + let Some(current_name) = current else { return false }; + if current_name == name { + return true; + } + current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base); + } + false + }) + .map(|(name, _)| (*name).to_string()) + .collect() +} + +pub(super) fn cell_style_edit(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); + let stroke = stroke_param(s, p.get("stroke"), "style.cell.edit")?; + let p = p.clone(); + s.edit(|d, _| { + let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?; + cell_style_fields(cs, &p, stroke, "style.cell.edit")?; + let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref()))); + reapply_cell_styles(d, &names); + ok() + }) +} + +fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: Option) { + if let Some(v) = p.get("basedOn") { + ts.based_on = v.as_str().map(str::to_string); + } + for (k, f) in [ + ("header", &mut ts.header), + ("body", &mut ts.body), + ("footer", &mut ts.footer), + ("leftColumn", &mut ts.left_column), + ("rightColumn", &mut ts.right_column), + ] { + if let Some(v) = p.get(k) { + *f = v.as_str().map(str::to_string); + } + } + if let Some(b) = border { + ts.border = Some(b); + ts.borders = Default::default(); + } + if let Some(a) = p.get("altRows").and_then(Value::as_object) { + let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX)); + let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string(); + let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32); + ts.alt_rows = Some(designcraft_doc::AltFills { + first: n("first", 1), + first_color: c("firstColor"), + first_tint: t("firstTint"), + next: n("next", 1), + next_color: c("nextColor"), + next_tint: t("nextTint"), + skip_first: n("skipFirst", 0), + skip_last: n("skipLast", 0), + }); + } + if let Some(v) = p.get("spaceBefore").and_then(Value::as_f64) { + ts.space_before = Some(v); + } + if let Some(v) = p.get("spaceAfter").and_then(Value::as_f64) { + ts.space_after = Some(v); + } +} + +pub(super) fn table_style_create(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Table Style 1").to_string(); + let mut ts = designcraft_doc::TableStyle::default(); + let border = stroke_param(s, p.get("border"), "style.table.create")?; + table_style_fields(&mut ts, p, border); + s.edit(|d, _| { + let name = designcraft_doc::Styles::unique_name(|n| d.styles.table.iter().any(|c| c.name == n), &base); + ts.name = name.clone(); + d.styles_mut().table.push(ts); + Ok(json!({"name": name})) + }) +} + +pub(super) fn table_style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string(); + let doc = &s.doc()?.doc; + if !doc.styles.table.iter().any(|c| c.name == name) { + return Err(bad("style.table.apply", format!("no table style `{name}`"))); + } + let ts = doc.styles.resolve_table_style(&name); + let cells = doc.styles.cell.clone(); + let g = target(s, p, "style.table.apply")?; + edit_table(s, &g, "style.table.apply", |t| { + ts.apply_to(t, &cells); + ok() + }) +} + +pub(super) fn table_style_edit(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string(); + let border = stroke_param(s, p.get("border"), "style.table.edit")?; + let p = p.clone(); + s.edit(|d, _| { + let ts = + d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?; + table_style_fields(ts, &p, border); + let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref()))); + let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect(); + let cells = d.styles.cell.clone(); + for sid in d.stories.keys().copied().collect::>() { + let Some(st) = d.story_mut(sid) else { continue }; + for t in st.tables.values_mut() { + if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) { + ts.apply_to(std::sync::Arc::make_mut(t), &cells); + } + } + st.rev = st.rev.wrapping_add(1); + } + ok() + }) +} + +#[cfg(test)] +mod style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap(); + let got = s.execute("table.get", &json!({})).unwrap(); + let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone(); + assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0))); + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err()); + // Cell Options › Text › Rotation: carried and written back, composed horizontally so far, + // which the IDML import reports (#1468). + for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] { + s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}")); + let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone(); + assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}"); + } + for bad in [45, 1, 100] { + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}"); + } + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err()); + s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap(); + let d = &s.doc().unwrap().doc; + let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap(); + assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading); + assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent); + } + + #[test] + fn cell_and_table_styles_apply_and_follow_edits() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Cell Head"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Head", "fill": "[Black]", "paragraphStyle": "Cell Head", "insets": 6})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Body", "fill": "[Paper]"})).unwrap(); + s.execute("style.table.create", &json!({"name": "Data", "header": "Head", "body": "Body", "border": {"weight": 2, "color": "[Black]"}})) + .unwrap(); + s.execute("style.table.apply", &json!({"name": "Data"})).unwrap(); + let t = |s: &Session| -> designcraft_doc::Table { + let d = &s.doc().unwrap().doc; + (**d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap()).clone() + }; + let tb = t(&s); + assert_eq!(tb.style, "Data"); + assert_eq!(tb.options.border.weight, 2.0); + let head = tb.cell(0, 0).unwrap(); + assert_eq!((head.fill.as_str(), head.style.as_str(), head.insets), ("[Black]", "Head", [6.0; 4])); + assert_eq!(head.text.paras[0].style, "Cell Head"); + assert_eq!(tb.cell(1, 1).unwrap().style, "Body"); + // Editing the cell style updates its cells. + s.execute("style.cell.edit", &json!({"name": "Head", "fill": "C=100 M=0 Y=0 K=0"})).unwrap(); + assert_eq!(t(&s).cell(0, 1).unwrap().fill, "C=100 M=0 Y=0 K=0"); + // Editing the table style re-applies it. + s.execute("style.table.edit", &json!({"name": "Data", "border": {"weight": 4}})).unwrap(); + assert_eq!(t(&s).options.border.weight, 4.0); + assert!(s.execute("style.cell.apply", &json!({"name": "Nope"})).is_err()); + } +} + +#[cfg(test)] +mod style_alt_rows_tests { + use serde_json::json; + + use super::table_style_fields; + + #[test] + fn style_alt_rows_counts_saturate() { + let mut ts = designcraft_doc::TableStyle::default(); + let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}}); + table_style_fields(&mut ts, &p, None); + let a = ts.alt_rows.unwrap(); + assert_eq!(a.first, u32::MAX, "first"); + assert_eq!(a.next, u32::MAX, "next"); + assert_eq!(a.skip_first, u32::MAX, "skip_first"); + assert_eq!(a.skip_last, 3, "skip_last"); + } +} diff --git a/crates/engine/src/cmd/table/target.rs b/crates/engine/src/cmd/table/target.rs new file mode 100644 index 0000000000..ee3b9c785d --- /dev/null +++ b/crates/engine/src/cmd/table/target.rs @@ -0,0 +1,157 @@ +use super::*; + +// ---------- enablement and targeting ---------- + +pub(super) fn in_table(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let st = s.active().ok_or("no document open")?; + if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { + return Ok(()); + } + if let Some(t) = st.selection.text + && st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some()) + { + return Ok(()); + } + Err("place the insertion point in a table".into()) +} + +pub(super) fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { + has_doc(s) +} + +pub(super) fn in_cell(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } +} + +/// The table a command acts on and its target cell range. +pub(super) struct Tgt { + pub(super) story: StoryId, + pub(super) table: u64, + pub(super) range: CellRange, +} + +fn find_table(doc: &Document, id: u64) -> Option { + doc.stories.iter().find(|(_, s)| s.tables.contains_key(&id)).map(|(k, _)| *k) +} + +fn pair(p: &Value, many: &str, one: &str) -> Option<(usize, usize)> { + if let Some(a) = p.get(many).and_then(Value::as_array) { + let a0 = a.first()?.as_u64()? as usize; + let a1 = a.get(1).and_then(Value::as_u64).map_or(a0, |v| v as usize); + return Some((a0.min(a1), a0.max(a1))); + } + p.get(one).and_then(Value::as_u64).map(|v| (v as usize, v as usize)) +} + +pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let doc = &st.doc; + let explicit_table = p.get("table").and_then(Value::as_u64); + let explicit_story = p.get("story").and_then(Value::as_u64).map(StoryId); + let (story, table, range) = if let Some(tid) = explicit_table { + let sid = explicit_story.or_else(|| find_table(doc, tid)).ok_or_else(|| bad(cmd, format!("no table {tid}")))?; + (sid, tid, None) + } else if let Some(c) = st.selection.cells { + (c.story, c.table, Some(c.range)) + } else if let Some(t) = st.selection.text { + match t.cell { + Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))), + None => { + let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?; + (t.story, tid, None) + } + } + } else { + return Err(bad(cmd, "place the insertion point in a table")); + }; + let t = doc.story(story).and_then(|x| x.tables.get(&table)).ok_or_else(|| bad(cmd, format!("no table {table}")))?; + let (nr, nc) = (t.nrows(), t.ncols()); + let mut range = range.unwrap_or(CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }); + if let Some((a, b)) = pair(p, "rows", "row") { + range.r0 = a; + range.r1 = b; + } + if let Some((a, b)) = pair(p, "cols", "col") { + range.c0 = a; + range.c1 = b; + } + if range.r1 >= nr || range.c1 >= nc { + return Err(bad(cmd, format!("cell range out of bounds ({nr}×{nc} table)"))); + } + Ok(Tgt { story, table, range }) +} + +/// Edit the target table; afterwards selections pointing into it are clamped to its new shape. +pub(super) fn edit_table(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result) -> Result { + let cmd = cmd.to_string(); + s.edit(|d, sel| { + let st = d.story_mut(g.story).ok_or_else(|| bad(&cmd, "no story"))?; + let t = st.table_mut(g.table).ok_or_else(|| bad(&cmd, "no table"))?; + let r = f(t)?; + clamp_selection(d, sel); + Ok(r) + }) +} + +/// Keep text/cell selections valid after the table changed shape (or disappeared). +pub(super) fn clamp_selection(d: &Document, sel: &mut Selection) { + if let Some(ts) = sel.cells { + match d.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { + Some(t) => { + let (mr, mc) = (t.nrows() - 1, t.ncols() - 1); + let r = ts.range; + sel.cells = Some(TableSel { range: CellRange::new(r.r0.min(mr), r.c0.min(mc), r.r1.min(mr), r.c1.min(mc)), ..ts }); + } + None => sel.cells = None, + } + } + if let Some(t) = sel.text + && let Some(c) = t.cell + { + let story = d.story(t.story); + match story.and_then(|x| x.tables.get(&c.table)) { + Some(tb) => { + let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); + let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); + sel.text = Some(TextSel { + anchor: floor_char_boundary(text, t.anchor), + focus: floor_char_boundary(text, t.focus), + cell: Some(CellAddr { table: c.table, row: r, col }), + ..t + }); + } + None => { + let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); + sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); + } + } + } +} + +pub(super) fn count(p: &Value) -> usize { + p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize +} + +pub(super) fn color_param(p: &Value, key: &str) -> Option { + str_param(p, key).map(str::to_string) +} + +// ---------- create / convert ---------- + +/// Width of the text column at the caret (the new table spans it). +pub(super) fn column_width(s: &Session, t: &TextSel) -> f64 { + let Some(st) = s.active() else { return 300.0 }; + let specs = compose::frame_specs(&st.doc, t.story); + let spec = t.frame.and_then(|f| specs.iter().find(|x| x.id == f)).or(specs.first()); + spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0) +} + +/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style +/// chain, then local overrides). A table created there takes it. +pub(super) fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection { + d.story(story) + .and_then(|st| st.paras.get(st.para_at(offset))) + .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f8e5177dac..8e6038741b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -9,6 +9,29 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, has_text, has_text_or_frames, id_param, ok, point_param, str_param}; use crate::{Result, Session}; +mod asian; +mod caret; +mod edit; +mod format; +mod opentype; +mod story; + +use asian::{auto_tcy_cmd, grid_settings_cmd, kenten_cmd, ruby_cmd, shatai_cmd, warichu_cmd}; +pub(crate) use caret::story_of; +use caret::{move_caret, para_bounds, place, release, select_around, select_at, sentence_bounds}; +pub use caret::{next_char, next_word, prev_char, prev_word}; +pub(crate) use edit::delete_selection; +pub use edit::{PLACEHOLDER, quote_marks}; +use edit::{change_case, delete, delete_surrounding, fill_placeholder, insert}; +pub(crate) use format::{ + Target, TypingFormat, apply_char_attrs, effective_char_format, format_chars, format_paras, format_targets, format_targets_pub, + rename_typing_swatch, validate_char_fill, +}; +use format::{break_link, kern_pair, kern_step, link_style, para_toggle, pick_up_format, step_size, swap_fill_stroke}; +use opentype::{open_type, open_type_features, set_features}; +pub(crate) use story::set_story_direction; +use story::{selection_attrs, story_direction, story_options}; + /// Build the text command registry: caret/selection movement, insertion/deletion, release, text and paragraph formatting, story queries, ruby and OpenType commands, with their parameters and enabled predicates. pub fn specs() -> Vec { vec![ @@ -409,3874 +432,3 @@ pub fn specs() -> Vec { ), ] } - -pub(crate) fn story_of(s: &Session, p: &Value) -> Option { - let st = s.active()?; - if let Some(v) = p.get("story").and_then(Value::as_u64) { - return Some(StoryId(v)); - } - let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()); - if let Some(f) = frame { - return st.doc.item(f).and_then(|i| i.text_frame()).map(|t| t.story); - } - st.selection.text.map(|t| t.story) -} - -/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell). -fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option)> { - hit_byte_with(s, frame, pt, compose::hit) -} - -/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`] -/// (the character under the point). -fn hit_byte_with( - s: &Session, - frame: ItemId, - pt: Point, - hit: fn(&compose::ComposedStory, usize, Point) -> Option, -) -> Option<(StoryId, usize, Option)> { - let st = s.active()?; - let loc = st.doc.find(frame)?; - let it = st.doc.item_at(&loc)?; - let sid = it.text_frame()?.story; - let xf = st.doc.parent_xf(&loc) * st.doc.text_xf(it); - let inner = xf.inverse() * pt; - let cs = s.cache.get(&st.doc, sid, None); - let fi = cs.frames.iter().position(|f| f.frame == frame)?; - // Map path-text hits along the curve before choosing the character unit. - let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); - if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) { - return Some((sid, b, Some(CellAddr { table, row, col }))); - } - if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) { - return Some((sid, b, Some(CellAddr::footnote(id)))); - } - let b = hit(&cs, fi, inner).unwrap_or(0); - Some((sid, b, None)) -} - -/// The Eyedropper with text selected: the formatting of the text under `point` in `frame` goes to -/// the selection, as InDesign's does (character style and overrides on the selected characters, -/// paragraph style and overrides on their paragraphs). -fn pick_up_format(s: &mut Session, p: &Value) -> Result { - const ID: &str = "type.pickUpFormat"; - let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; - let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; - let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; - let st = s.doc()?; - let sel = st.selection.text.ok_or_else(|| bad(ID, "select the text to format"))?; - let src = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let b = floor_char_boundary(&src.text, b); - // The character under the pointer (at the end of a line, the one before it). - let at = if b >= src.text.len() || src.text[b..].starts_with('\n') { b.saturating_sub(1) } else { b }; - let at = floor_char_boundary(&src.text, at); - let chars = src.char_format_at(at).clone(); - let para = src.paras.get(src.para_at(at)).cloned().ok_or(designcraft_doc::DocError::NoStory(sid))?; - let (a, e) = (sel.anchor.min(sel.focus), sel.anchor.max(sel.focus)); - s.typing_format = None; - s.edit(|d, _| { - let story = d.text_story_mut(sel.story, sel.cell).ok_or(designcraft_doc::DocError::NoStory(sel.story))?; - if e > a { - story.format_chars(a..e, |f| *f = chars.clone()); - } - story.format_paras(a..e, |f| { - f.style = para.style.clone(); - f.para = para.para.clone(); - }); - Ok(json!({"style": chars.style, "paragraphStyle": para.style})) - }) -} - -/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks). -fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { - const ID: &str = "text.selectAt"; - let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; - let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; - let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; - let (a, e) = unit_at(s, sid, cell, b, unit, ID)?; - s.text_drag = false; - s.typing_format = None; - let sel = TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }; - s.text_unit = Some(sel); - let st = s.doc_mut()?; - st.selection = Selection::text(sel); - // Explicit reselection invalidates the Find scope even when the range is unchanged. - st.bump_revision(); - Ok(json!({"story": sid.0, "anchor": a, "focus": e})) -} - -/// The `unit` (word, line, paragraph or story) around byte `b` of story `sid` (a cell's story -/// with `cell`), as the multiple clicks select it. -fn unit_at(s: &Session, sid: StoryId, cell: Option, b: usize, unit: &str, cmd: &str) -> Result<(usize, usize)> { - let st = s.doc()?; - let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let text = story.text.as_str(); - let b = floor_char_boundary(text, b); - Ok(match unit { - "word" => word_or_spaces_at(text, b), - "line" => { - let cs = s.cache.get(&st.doc, sid, None); - line_at(&text_composition(cs, cell), b).unwrap_or((b, b)) - } - // The paragraph with its return (the last paragraph has none). - "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))), - "story" => (0, text.len()), - other => return Err(bad(cmd, format!("unknown unit `{other}` (word, line, paragraph or story)"))), - }) -} - -/// The word at byte `i`; on spaces the run of spaces; on anything else that one character -/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split: -/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base. -fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { - use unicode_segmentation::UnicodeSegmentation; - let i = floor_char_boundary(s, i.min(s.len())); - // Clusters never span a paragraph return, so the paragraph is enough. - let ps = s[..i].rfind('\n').map_or(0, |k| k + 1); - let pe = s[i..].find('\n').map_or(s.len(), |k| i + k); - let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect(); - let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) }; - let kind = |c: &str| match c.chars().next() { - Some(c) if is_word(c) => 1, - Some(c) if c.is_whitespace() => 2, - _ => 0, - }; - let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len())); - let kd = g.get(k).map_or(0, |(_, c)| kind(c)); - if kd == 0 { - return span(k, k); - } - let mut a = k; - while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) { - a -= 1; - } - let mut e = k; - while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) { - e += 1; - } - span(a, e) -} - -/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's. -fn text_composition(cs: std::sync::Arc, cell: Option) -> std::sync::Arc { - match cell { - Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs), - Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs), - None => cs, - } -} - -/// The composed line holding the character at byte `pos`. -fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { - let lines = || cs.frames.iter().flat_map(|f| f.lines.iter()); - lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos)) -} - -fn place(s: &mut Session, p: &Value, extend: bool) -> Result { - let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; - let pt = point_param(p, "point").unwrap_or(Point::ZERO); - // Empty frames become text frames; a locked one stays as it is. - let st = s.doc()?; - let locked = st.doc.item(frame).is_some_and(|i| i.locked || st.doc.layer(i.layer).is_some_and(|l| l.locked)); - let needs_convert = st.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); - if needs_convert && locked { - return Err(bad("text.placeCaret", "the frame is locked")); - } - if needs_convert { - s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?; - } - let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?; - // A drag after several clicks: whole units from the one first selected, in either direction. - if extend - && let Some(unit) = str_param(p, "unit") - && let Some(u) = s.text_unit.filter(|u| u.story == sid && u.cell == cell) - { - // The remembered unit may predate edits that shortened the text. - let text = s.doc()?.doc.text_story(sid, cell).map_or("", |t| t.text.as_str()); - let (ua, ue) = (floor_char_boundary(text, u.anchor.min(u.focus)), floor_char_boundary(text, u.anchor.max(u.focus))); - let (na, ne) = unit_at(s, sid, cell, b, unit, "text.extendTo")?; - let (anchor, focus) = if na < ua { (ue, na) } else { (ua, ne.max(ue)) }; - s.typing_format = None; - let st = s.doc_mut()?; - st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); - st.bump_revision(); - return Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})); - } - // Drag and drop: a press inside the selected text keeps it (to be dragged). - if !extend { - s.text_drag = s.doc()?.selection.text.is_some_and(|t| t.story == sid && t.cell == cell && !t.range().is_empty() && t.range().contains(&b)); - if s.text_drag { - return Ok(json!({"story": sid.0, "dragging": true})); - } - } else if s.text_drag { - return Ok(json!({"story": sid.0, "dragging": true})); - } - s.typing_format = None; - let st = s.doc_mut()?; - // Dragging from one cell into another selects cells. - if extend - && let (Some(t), Some(c)) = (st.selection.text, cell) - && let Some(a) = t.cell - && t.story == sid - && a.table == c.table - && (a.row, a.col) != (c.row, c.col) - { - let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col); - st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range }); - st.bump_revision(); - return Ok(json!({"story": sid.0, "cells": range})); - } - let t = match (extend, st.selection.text) { - (true, Some(t)) if t.story == sid && t.cell == cell => TextSel { focus: b, frame: Some(frame), ..t }, - _ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell }, - }; - st.selection = Selection::text(t); - st.bump_revision(); - Ok(json!({"story": sid.0, "pos": b, "cell": cell})) -} - -fn release(s: &mut Session, p: &Value) -> Result { - if !std::mem::take(&mut s.text_drag) { - return ok(); - } - let frame = id_param(p, "frame").ok_or_else(|| bad("text.release", "missing frame"))?; - let pt = point_param(p, "point").unwrap_or(Point::ZERO); - if !p.get("moved").and_then(Value::as_bool).unwrap_or(false) { - // A click in the selection: the caret goes there. - s.doc_mut()?.selection.text = None; - return place(s, &json!({"frame": frame.0, "point": [pt.x, pt.y]}), false); - } - let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); - let Some(t) = s.doc()?.selection.text else { return ok() }; - let Some((sid, b, cell)) = hit_byte(s, frame, pt) else { return ok() }; - let r = t.range(); - if sid != t.story || cell != t.cell || (r.contains(&b) && !copy) { - return ok(); - } - s.edit(|d, sel| { - let st = d.text_story_mut(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let piece = st.extract(r.clone()); - let mut at = b; - if !copy { - st.delete(r.clone()); - if at > r.start { - at -= r.len().min(at - r.start); - } - } - let end = st.insert_story(at, &piece); - sel.text = Some(TextSel { story: sid, anchor: at, focus: end, frame: Some(frame), cell }); - Ok(json!({"moved": piece.text.len(), "at": at})) - }) -} - -// Each command edits a copy-on-write Story snapshot. Reserving its known final text growth -// avoids retaining geometric String spare capacity in every independent undo snapshot. -// General Story insertion keeps its existing amortized-growth policy for mutable builders. -fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> Result<()> { - st.text.try_reserve_exact(additional).map_err(|e| crate::EngineError::Other(format!("could not reserve text insertion: {e}"))) -} - -fn insert(s: &mut Session, p: &Value) -> Result { - let text = super::text_param(p, "text"); - // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). - if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { - return super::table::step_cell(s, true); - } - let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); - let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; - let tracking = s.doc()?.doc.settings.track_changes; - let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone()); - let typing = typing_format(s).cloned(); - let result = s.edit(|d, sel| { - let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?; - let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let r = t.range(); - let mut r = r.start.min(st.len())..r.end.min(st.len()); - // Between a break character and its return is the end of that paragraph: typing there goes - // to the start of the next one. - if r.is_empty() - && st.text.get(r.start..).is_some_and(|t| t.starts_with('\n')) - && st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char) - { - r = r.start + 1..r.start + 1; - } - // Autocorrect: a word ended by a space or punctuation is looked up. - if let Some(list) = &autocorrect - && r.is_empty() - && text.chars().count() == 1 - && text.chars().all(|c| c.is_whitespace() || c.is_ascii_punctuation()) - { - let before = &st.text[..r.start]; - let ws = before.char_indices().rev().take_while(|(_, c)| c.is_alphabetic()).last().map(|(i, _)| i); - if let Some(ws) = ws { - let word = &before[ws..]; - if let Some((_, to)) = list.iter().find(|(from, _)| from.eq_ignore_ascii_case(word)) { - let to = if word.chars().next().is_some_and(char::is_uppercase) { - let mut c = to.chars(); - c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() - } else { - to.clone() - }; - st.replace(ws..r.start, &to); - r = ws + to.len()..ws + to.len(); - } - } - } - // A column, frame or page break ends its paragraph. - let mut text = if text.contains(designcraft_doc::is_break_char) { - designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next())) - } else { - text.clone() - }; - // Typing next to a table anchor starts a paragraph of its own. - let mut trail = 0; - if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() { - let pi = st.para_at(r.start); - let pr = st.para_ranges()[pi].clone(); - let anchor_at = pr.start + st.text[pr.clone()].find(designcraft_doc::TABLE_ANCHOR).unwrap_or(0); - if r.start <= anchor_at { - if !text.ends_with('\n') { - text.push('\n'); - trail = 1; - } - } else if !text.starts_with('\n') { - text.insert(0, '\n'); - } - } - if tracking && t.cell.is_none() { - // Track Changes: the replaced text is marked deleted, the typing inserted after it. - let at = super::changes::mark_deleted(st, r.clone()); - reserve_command_text(st, text.len())?; - let mut fmt = st.insert_format_at(at).clone(); - if let Some(f) = &typing { - apply_typing_format(&mut fmt, f); - } - fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted); - st.insert_with(at, &text, fmt); - let pos = at + text.len() - trail; - sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); - return Ok(json!({"pos": pos})); - } - let a = floor_char_boundary(&st.text, r.start.min(st.len())); - let b = floor_char_boundary(&st.text, r.end.min(st.len())).max(a); - reserve_command_text(st, text.len().saturating_sub(b - a))?; - st.replace(r.clone(), &text); - if let Some(f) = &typing - && !text.is_empty() - { - st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f)); - } - let pos = r.start + text.len() - trail; - sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); - Ok(json!({"pos": pos})) - }); - // A preview (IME marked text) leaves the typing format to the text typed in the end. - if result.is_ok() && s.active().is_none_or(|d| d.interaction.is_none()) { - s.typing_format = None; - } - result -} - -/// The input method's delete-surrounding: remove `before`/`after` characters (or bytes) around -/// the selection, keeping the selection itself and its direction, then optionally type `commit` -/// over it. One undo step for the whole batch, through the normal command pipeline (preview -/// restoration, panic guard, undo, auto-size and the journal), unlike a private edit. -fn delete_surrounding(s: &mut Session, p: &Value) -> Result { - let count = |key: &str| -> Result { - match p.get(key).and_then(Value::as_u64) { - Some(v) => usize::try_from(v).map_err(|_| bad("text.deleteSurrounding", format!("{key} is out of range"))), - None => Ok(0), - } - }; - let before = count("before")?; - let after = count("after")?; - let bytes = p.get("bytes").and_then(Value::as_bool).unwrap_or(false); - let commit = designcraft_tools::ime_commit_text(p.get("commit").and_then(Value::as_str).unwrap_or("")); - // What the request removes, from the committed text as it is now: the ranges exclude the - // selection and any pre-edit (a pre-edit is only a preview, never in the story). - let (before_range, after_range, tracking, length_before) = { - let st = s.doc()?; - let t = st.selection.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; - let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let (before_range, after_range) = crate::ime_delete_ranges(&story.text, t.anchor, t.focus, before, after, bytes) - .ok_or_else(|| bad("text.deleteSurrounding", "the IME text selection is not on character boundaries"))?; - (before_range, after_range, st.doc.settings.track_changes && t.cell.is_none(), story.text.len()) - }; - s.edit(|d, sel| { - let t = sel.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; - let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - if !after_range.is_empty() { - if tracking { - crate::cmd::changes::mark_deleted(story, after_range.clone()); - } else { - story.delete(after_range.clone()); - } - } - let mut shift = 0usize; - if !before_range.is_empty() { - if tracking { - // Tracked changes keep the text and mark it deleted: the selection does not move. - let new_end = crate::cmd::changes::mark_deleted(story, before_range.clone()); - shift = before_range.end.saturating_sub(new_end); - } else { - shift = before_range.len(); - story.delete(before_range.clone()); - } - } - sel.text = Some(TextSel { anchor: t.anchor.saturating_sub(shift), focus: t.focus.saturating_sub(shift), ..t }); - Ok(()) - })?; - if !commit.is_empty() { - insert(s, &json!({"text": commit}))?; - } - let st = s.doc()?; - let (anchor, focus) = st.selection.text.map_or((0, 0), |t| (t.anchor, t.focus)); - let length_after = st.selection.text.and_then(|t| st.doc.text_story(t.story, t.cell)).map_or(0, |story| story.text.len()); - Ok(json!({ - "deletedBefore": [before_range.start, before_range.end], - "deletedAfter": [after_range.start, after_range.end], - "lengthBefore": length_before, - "lengthAfter": length_after, - "anchor": anchor, - "focus": focus, - })) -} - -fn smart_quotes(s: &Session, text: &str) -> String { - if !text.contains(['"', '\'']) { - return text.to_string(); - } - let prev = s - .active() - .and_then(|st| { - let t = st.selection.text?; - let story = st.doc.text_story(t.story, t.cell)?; - story.text[..t.range().start.min(story.len())].chars().last() - }) - .unwrap_or(' '); - // The language at the insertion point picks the quote marks. - let language = s - .active() - .and_then(|st| { - let t = st.selection.text?; - let story = st.doc.text_story(t.story, t.cell)?; - let pos = t.range().start.min(story.len()); - let pi = story.para_at(pos).min(story.paras.len().saturating_sub(1)); - let base = st.doc.styles.resolve_para(story.paras.get(pi)?).1; - Some(st.doc.styles.resolve_char(&base, story.char_format_at(pos)).language) - }) - .unwrap_or_default(); - let [dq_open, dq_close, sq_open, sq_close] = quote_marks(&language); - let mut out = String::with_capacity(text.len()); - let mut last = prev; - for c in text.chars() { - let open = last.is_whitespace() || matches!(last, '(' | '[' | '{' | '\u{2014}' | '\u{2013}'); - let r = match c { - '"' => { - if open { - dq_open - } else { - dq_close - } - } - '\'' => { - if open { - sq_open - } else { - // An apostrophe inside a word stays an apostrophe. - if last.is_alphanumeric() { '\u{2019}' } else { sq_close } - } - } - c => c, - }; - out.push(r); - last = c; - } - out -} - -/// Typographer's quotes of a language (a name or locale code, see -/// [`designcraft_doc::language_tag`]): [double open, double close, single open, single close]. -pub fn quote_marks(language: &str) -> [char; 4] { - const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; - let Some(tag) = designcraft_doc::language_tag(language) else { return ENGLISH }; - // Swiss German, French and Italian use guillemets. - if tag.ends_with("-CH") { - return ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; - } - match tag { - "zh-Hans" => return ENGLISH, - "zh" | "zh-Hant" => return ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], - _ => {} - } - match designcraft_doc::language_subtag(tag) { - "de" | "cs" | "sk" | "bg" | "lt" => ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'], - "fr" | "ru" | "uk" | "no" | "nb" | "nn" | "el" => ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'], - "es" | "it" | "pt" | "ca" => ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'], - "nl" | "pl" | "ro" | "hu" | "hr" => ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'], - "sv" | "fi" => ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'], - "da" => ['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}'], - "ja" => ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], - _ => ENGLISH, - } -} - -pub(crate) fn delete_selection(s: &mut Session) -> Result { - let tracking = s.doc()?.doc.settings.track_changes; - s.edit(|d, sel| { - let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; - let r = t.range(); - if let Some(st) = d.text_story_mut(t.story, t.cell) { - if tracking && t.cell.is_none() { - super::changes::mark_deleted(st, r.clone()); - } else { - st.delete(r.clone()); - } - } - sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); - ok() - }) -} - -fn delete(s: &mut Session, p: &Value) -> Result { - let forward = bool_or(p, "forward", false); - let word = bool_or(p, "word", false); - let tracking = s.doc()?.doc.settings.track_changes; - s.edit(|d, sel| { - let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; - let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let r = t.range(); - let r = if !r.is_empty() { - r - } else if forward { - let end = if word { next_word(&st.text, r.start) } else { next_char(&st.text, r.start) }; - r.start..end - } else { - let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) }; - start..r.start - }; - let r = with_break_pair(&st.text, r); - if tracking && t.cell.is_none() { - // Marked, not removed: the caret steps over the deleted text. - let at = super::changes::mark_deleted(st, r.clone()); - let pos = if forward { at.max(r.end.min(st.len())) } else { r.start.min(at) }; - sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); - return ok(); - } - st.delete(r.clone()); - sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); - ok() - }) -} - -/// A break character and the return after it end one paragraph: deleting either deletes both. -fn with_break_pair(text: &str, r: std::ops::Range) -> std::ops::Range { - let Some(del) = text.get(r.clone()) else { return r }; - let mut chars = del.chars(); - match (chars.next(), chars.next()) { - (Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) { - Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end, - _ => r, - }, - (Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1, - _ => r, - } -} - -pub fn next_char(s: &str, i: usize) -> usize { - s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) -} -pub fn prev_char(s: &str, i: usize) -> usize { - s[..i.min(s.len())].chars().next_back().map(|c| i - c.len_utf8()).unwrap_or(0) -} -fn is_word(c: char) -> bool { - c.is_alphanumeric() || c == '\'' || c == '’' -} -pub fn next_word(s: &str, i: usize) -> usize { - let mut j = i; - let mut seen = false; - for (k, c) in s[i..].char_indices() { - if is_word(c) { - seen = true; - } else if seen { - return i + k; - } - j = i + k + c.len_utf8(); - } - j -} -pub fn prev_word(s: &str, i: usize) -> usize { - let mut seen = false; - for (k, c) in s[..i].char_indices().rev() { - if is_word(c) { - seen = true; - } else if seen { - return k + c.len_utf8(); - } - } - 0 -} -/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret -/// placement, a press inside the current selection is no start of a text drag here: it is the -/// third or later click of a row, which lands in the word or sentence just selected. -fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { - let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; - let pt = point_param(p, "point").unwrap_or(Point::ZERO); - let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; - let st = s.doc_mut()?; - let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); - let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); - s.text_drag = false; - s.typing_format = None; - let st = s.doc_mut()?; - st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); - st.bump_revision(); - Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) -} - -/// The paragraph around byte `i`, without its `\n`. -fn para_bounds(s: &str, i: usize) -> (usize, usize) { - let i = i.min(s.len()); - let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); - let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); - (a, b) -} - -/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and -/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full -/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. -fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { - let (pa, pb) = para_bounds(s, i); - let para = s.get(pa..pb).unwrap_or(""); - let mut start = 0; - let mut it = para.char_indices().peekable(); - while let Some((_, c)) = it.next() { - let cjk = matches!(c, '。' | '!' | '?'); - if !cjk && !matches!(c, '.' | '!' | '?' | '…') { - continue; - } - while it.peek().is_some_and(|&(_, c)| { - matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') - }) { - it.next(); - } - if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { - continue; - } - while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { - it.next(); - } - let end = it.peek().map_or(para.len(), |&(k, _)| k); - if pa + end > i || end == para.len() { - return (pa + start, pa + end); - } - start = end; - } - (pa + start, pb) -} - -/// The stories of the text selection, the selected cells and the selected text frames. -fn selected_stories(s: &Session) -> Result> { - let st = s.doc()?; - let mut stories: Vec = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect(); - stories.dedup(); - for id in &st.selection.items { - if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) - && !stories.contains(&tf.story) - { - stories.push(tf.story); - } - } - Ok(stories) -} - -/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size). -fn story_options(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "story.options"; - let on = match p.get("opticalMargin") { - None => None, - Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?), - }; - let size = match p.get("opticalMarginSize") { - None => None, - Some(v) => Some( - v.as_f64() - .and_then(designcraft_doc::OpticalMargin::clamp_size) - .ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?, - ), - }; - if on.is_none() && size.is_none() { - return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize")); - } - let explicit = |key| p.get(key).filter(|v| !v.is_null()); - let stories = if explicit("story").is_some() || explicit("frame").is_some() { - // Explicit targets must not silently fall back to the current selection. - let doc = &s.doc()?.doc; - for key in ["story", "frame"] { - if let Some(value) = explicit(key) { - let id = value.as_u64().ok_or_else(|| bad(CMD, format!("`{key}` must be an unsigned integer")))?; - let sid = if key == "story" { - StoryId(id) - } else { - let item = doc.item(ItemId(id)).ok_or_else(|| bad(CMD, format!("no such frame {id}")))?; - item.text_frame().ok_or_else(|| bad(CMD, format!("frame {id} holds no text")))?.story - }; - if doc.story(sid).is_none() { - return Err(bad(CMD, format!("no such story {}", sid.0))); - } - } - } - vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?] - } else { - selected_stories(s)? - }; - if stories.is_empty() { - return Err(bad(CMD, "no story: select text or a text frame, or give story or frame")); - } - s.edit(|d, _| { - for sid in &stories { - let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?; - let mut om = st.optical_margin; - om.on = on.unwrap_or(om.on); - om.size = size.unwrap_or(om.size); - if om != st.optical_margin { - st.optical_margin = om; - st.rev = st.rev.wrapping_add(1); - } - } - Ok(json!({"stories": stories.len()})) - }) -} - -/// Type ▸ Story Direction. -fn story_direction(s: &mut Session, p: &Value) -> Result { - let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; - let stories = selected_stories(s)?; - s.edit(|d, _| { - set_story_direction(d, &stories, vertical); - Ok(json!({"stories": stories.len()})) - }) -} - -/// Set the direction of `stories` (all their frames turn together). -pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[StoryId], vertical: bool) { - for sid in stories { - if let Some(st) = d.story_mut(*sid) - && st.vertical != vertical - { - st.vertical = vertical; - st.rev = st.rev.wrapping_add(1); - } - } -} - -fn move_caret(s: &mut Session, p: &Value) -> Result { - let dir = str_param(p, "dir").unwrap_or("right").to_string(); - let extend = bool_or(p, "extend", false); - let word = bool_or(p, "word", false); - let st = s.doc()?; - let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?; - let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let text = story.text.clone(); - // In a cell, lines come from the cell's own composition. - let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell); - let pos = t.focus.min(text.len()); - // Vertical lines run top to bottom and follow each other right to left: Down and Up move along - // the line (Right and Left of horizontal text), Left and Right to the next and previous line. - let vertical_text = compose::caret(&cs, pos).and_then(|(fi, ..)| cs.frames.get(fi)).is_some_and(|f| f.vertical); - let dir = match (vertical_text, dir.as_str()) { - (true, "down") => "right", - (true, "up") => "left", - (true, "left") => "down", - (true, "right") => "up", - (_, d) => d, - }; - let collapse_to = |left: bool| if left { t.range().start } else { t.range().end }; - let new = match dir { - "left" if !extend && !t.is_caret() => collapse_to(true), - "right" if !extend && !t.is_caret() => collapse_to(false), - "left" | "right" => { - // Lines with right-to-left text move as drawn; at a line's edge, or by words, a - // right-to-left line's "left" is forward in the text. - let left = dir == "left"; - let visual = if word { None } else { compose::visual_step(&cs, pos, left) }; - match visual { - Some(p) => p, - None => { - let forward = left == compose::line_rtl(&cs, pos); - match (forward, word) { - (true, true) => next_word(&text, pos), - (true, false) => next_char(&text, pos), - (false, true) => prev_word(&text, pos), - (false, false) => prev_char(&text, pos), - } - } - } - } - "up" | "down" => vertical(&cs, pos, dir == "up").unwrap_or(pos), - "lineStart" => line_of(&cs, pos).map(|l| l.0).unwrap_or(0), - "lineEnd" => line_of(&cs, pos).map(|l| l.1).unwrap_or(text.len()), - "storyStart" => 0, - "storyEnd" => text.len(), - other => return Err(bad("text.move", format!("unknown dir `{other}`"))), - }; - s.typing_format = None; - let stm = s.doc_mut()?; - stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); - stm.bump_revision(); - Ok(json!({"pos": new})) -} - -fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { - cs.frames.iter().flat_map(|f| f.lines.iter()).find(|l| pos >= l.range.start && pos <= l.range.end).map(|l| (l.range.start, l.range.end)) -} - -fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option { - let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?; - let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect(); - // Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it - // does not equal the parent line's baseline. - let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?; - let (_, l) = lines.get(cur)?; - if let Some(y) = compose::adjacent_row(l, x, caret_y, up) - && let Some(p) = compose::hit(cs, fi, Point::new(x, y)) - && p != pos - { - return Some(p); - } - // The next row: segments of the same line (beside a wrap object) share its baseline. - let other_row = |(i, t): &&(usize, &compose::Line)| *i != fi || (t.baseline - l.baseline).abs() > 1e-6; - let (tf, tl) = if up { lines.get(..cur)?.iter().rfind(other_row)? } else { lines.get(cur + 1..)?.iter().find(other_row)? }; - compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0)) -} - -/// Where a formatting command applies: a story (or a table cell's story) and a byte range. -#[derive(Clone, Debug)] -pub(crate) struct Target { - pub story: StoryId, - pub cell: Option, - pub range: std::ops::Range, -} - -/// Ranges and stories a formatting command applies to: selected table cells (whole cells), the -/// text selection, or whole stories of the selected text frames. -pub(crate) fn format_targets(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; - if let Some(ts) = st.selection.cells - && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) - { - let mut v = Vec::new(); - for (r, c) in t.selected_owners(ts.range) { - let len = t.cell(r, c).map_or(0, |x| x.text.len()); - v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); - } - return v; - } - if let Some(t) = st.selection.text { - return vec![Target { story: t.story, cell: t.cell, range: t.range() }]; - } - let mut v: Vec = Vec::new(); - for id in &st.selection.items { - if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) { - let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0); - if !v.iter().any(|t| t.story == tf.story) { - v.push(Target { story: tf.story, cell: None, range: 0..len }); - } - } - } - v -} - -/// Character formatting chosen at a bare caret in text: applied to what is typed there next. -#[derive(Clone, Debug)] -pub(crate) struct TypingFormat { - doc: u64, - at: TextSel, - attrs: CharAttrs, - /// Overrides removed (`null` in `type.char`). - cleared: Vec, -} - -/// The typing format for the active document's caret, if it was chosen at this caret. -fn typing_format(s: &Session) -> Option<&TypingFormat> { - let st = s.active()?; - let t = st.selection.text?; - s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range()) -} - -fn warichu_cmd(s: &mut Session, p: &Value) -> Result { - let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some()); - let on = match p.get("on").and_then(Value::as_bool) { - Some(v) => v, - None if has_opts => true, - None => !format_targets(s).first().is_some_and(|t| { - s.doc() - .ok() - .and_then(|d| d.doc.text_story(t.story, t.cell)) - .is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true)) - }), - }; - let mut body = serde_json::Map::new(); - body.insert("warichu".into(), json!(on)); - if on { - if let Some(v) = p.get("lines").and_then(Value::as_u64) { - body.insert("warichuLines".into(), json!(v.clamp(2, 16))); - } - if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) { - body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0))); - } - if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) { - body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0))); - } - if let Some(v) = p.get("align").and_then(Value::as_str) { - body.insert("warichuAlignment".into(), json!(v)); - } - if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) { - body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100))); - } - if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) { - body.insert("warichuCharsAfterBreak".into(), json!(v.min(100))); - } - } - format_chars(s, &Value::Object(body)) -} - -// Kenten, shatai, auto tate-chu-yoko and grid settings: validated settings for the CJK commands. - -/// Longest font, style or swatch name taken from parameters, in characters. -const NAME_MAX_CHARS: usize = 256; - -/// A number parameter: absent or `null` → `None`; anything but a finite number is an error. -fn finite_param(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => v.as_f64().filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))), - } -} - -/// A text parameter, cut to `max` characters: absent or `null` → `None`; not text is an error. -fn text_param(p: &Value, key: &str, cmd: &str, max: usize) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(t)) => Ok(Some(t.chars().take(max).collect())), - Some(_) => Err(bad(cmd, format!("`{key}` must be text"))), - } -} - -/// A boolean parameter: absent or `null` → `None`; not a boolean is an error. -fn bool_param(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))), - } -} - -/// An enumeration parameter checked against `T`, as its JSON value. -fn enum_param(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => { - let t: T = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?; - serde_json::to_value(t).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))) - } - } -} - -/// A ruby enum parameter, checked against its type. -fn ruby_enum(p: &Value, key: &str) -> Result> { - let Some(v) = p.get(key) else { return Ok(None) }; - serde_json::from_value::(v.clone()).map_err(|e| bad("type.ruby", format!("{key}: {e}")))?; - Ok(Some(v.clone())) -} - -/// A ruby number parameter clamped to `lo..=hi`, then divided by `div` (100 for percentages); -/// `null` gives `Value::Null` (automatic). -fn ruby_num(p: &Value, key: &str, lo: f64, hi: f64, div: f64) -> Result> { - match p.get(key) { - None => Ok(None), - Some(Value::Null) => Ok(Some(Value::Null)), - Some(v) => { - let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("type.ruby", format!("{key}: expected a finite number")))?; - Ok(Some(json!(x.clamp(lo, hi) / div))) - } - } -} - -/// Is `flag` on in the character format at the start of the first formatting target? -fn char_flag_on(s: &Session, flag: impl Fn(&CharAttrs) -> Option) -> bool { - format_targets(s).first().is_some_and(|t| { - s.doc() - .ok() - .and_then(|d| d.doc.text_story(t.story, t.cell)) - .is_some_and(|st| flag(&st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over) == Some(true)) - }) -} - -fn kenten_cmd(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::cjk_settings::{AdornmentOverprint, KENTEN_MARK_MAX_CHARS, KentenAlignment, KentenKind, KentenPosition}; - const CMD: &str = "type.kenten"; - let mut body = serde_json::Map::new(); - let mut set = |k: &str, v: Value| { - body.insert(k.to_string(), v); - }; - // Validate every setting first, so a bad one changes nothing. - if let Some(v) = enum_param::(p, "kind", CMD)? { - // A preset stores its mark as the character too, as IDML import does, so a custom - // character left from before isn't drawn in its place. - if let Some(mark) = serde_json::from_value::(v.clone()).ok().and_then(KentenKind::mark) { - set("kentenCharacter", json!(mark)); - } - set("kentenKind", v); - } - if let Some(c) = text_param(p, "character", CMD, KENTEN_MARK_MAX_CHARS)? { - set("kentenCharacter", json!(c.chars().filter(|c| !c.is_control()).collect::())); - } - for (key, attr) in [("font", "kentenFont"), ("fontStyle", "kentenFontStyle"), ("fill", "kentenFill"), ("stroke", "kentenStroke")] { - if let Some(t) = text_param(p, key, CMD, NAME_MAX_CHARS)? { - set(attr, json!(t)); - } - } - if let Some(v) = finite_param(p, "size", CMD)? { - set("kentenSize", json!(v.clamp(0.1, 1296.0))); - } - if let Some(v) = finite_param(p, "distance", CMD)? { - set("kentenDistance", json!(v.clamp(-1296.0, 1296.0))); - } - for (key, attr) in [("xScale", "kentenXScale"), ("yScale", "kentenYScale")] { - if let Some(v) = finite_param(p, key, CMD)? { - set(attr, json!(v.clamp(1.0, 1000.0) / 100.0)); - } - } - for (key, attr) in [("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint")] { - if let Some(v) = finite_param(p, key, CMD)? { - set(attr, json!(v.clamp(0.0, 100.0) / 100.0)); - } - } - if let Some(v) = finite_param(p, "strokeWeight", CMD)? { - set("kentenStrokeWeight", json!(v.clamp(0.0, 1000.0))); - } - if let Some(v) = enum_param::(p, "position", CMD)? { - set("kentenPosition", v); - } - if let Some(v) = enum_param::(p, "alignment", CMD)? { - set("kentenAlignment", v); - } - for (key, attr) in [("overprintFill", "kentenOverprintFill"), ("overprintStroke", "kentenOverprintStroke")] { - if let Some(v) = enum_param::(p, key, CMD)? { - set(attr, v); - } - } - // `null` returns a setting to the style's (size: half the text size by default). - for (key, attr) in - [("size", "kentenSize"), ("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint"), ("strokeWeight", "kentenStrokeWeight")] - { - if p.get(key).is_some_and(Value::is_null) { - set(attr, Value::Null); - } - } - let on = match bool_param(p, "on", CMD)? { - Some(v) => v, - None if !body.is_empty() => true, - None => !char_flag_on(s, |f| f.kenten), - }; - if !on { - // Off keeps the settings for the next time kenten are switched on. - body = serde_json::Map::new(); - } - body.insert("kenten".into(), json!(on)); - format_chars(s, &Value::Object(body)) -} - -fn shatai_cmd(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "type.shatai"; - let mut body = serde_json::Map::new(); - if let Some(v) = finite_param(p, "magnification", CMD)? { - body.insert("shataiMagnification".into(), json!(v.clamp(0.0, 90.0))); - } - if let Some(v) = finite_param(p, "angle", CMD)? { - body.insert("shataiAngle".into(), json!(v.rem_euclid(180.0))); - } - if let Some(v) = bool_param(p, "adjustRotation", CMD)? { - body.insert("shataiAdjustRotation".into(), json!(v)); - } - if let Some(v) = bool_param(p, "adjustTsume", CMD)? { - body.insert("shataiAdjustTsume".into(), json!(v)); - } - if body.is_empty() { - return Err(bad(CMD, "nothing to set: magnification, angle, adjustRotation or adjustTsume")); - } - format_chars(s, &Value::Object(body)) -} - -fn auto_tcy_cmd(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "type.autoTateChuYoko"; - let mut body = serde_json::Map::new(); - if let Some(v) = finite_param(p, "digits", CMD)? { - let max = f64::from(designcraft_compose::shape::AUTO_TCY_MAX); - body.insert("autoTcy".into(), json!(v.round().clamp(0.0, max) as u32)); - } - if let Some(v) = bool_param(p, "includeRoman", CMD)? { - body.insert("autoTcyIncludeRoman".into(), json!(v)); - } - if body.is_empty() { - return Err(bad(CMD, "nothing to set: digits or includeRoman")); - } - format_paras(s, &Value::Object(body)) -} - -fn grid_settings_cmd(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::cjk::CharacterAlignment as R; - const CMD: &str = "type.gridSettings"; - let mut body = serde_json::Map::new(); - let first_only = bool_param(p, "firstLineOnly", CMD)?; - match text_param(p, "alignment", CMD, 32)?.as_deref() { - None => { - if let Some(f) = first_only { - body.insert("gridAlign".into(), json!(if f { "firstLineOnly" } else { "allLines" })); - } - } - Some("none") => { - body.insert("gridAlign".into(), json!("none")); - } - Some(name) => { - let reference = match name { - "romanBaseline" | "baseline" => R::Baseline, - "emTop" => R::EmTop, - "emCenter" => R::EmCenter, - "emBottom" => R::EmBottom, - "icfTop" => R::IcfTop, - "icfBottom" => R::IcfBottom, - other => return Err(bad(CMD, format!("unknown alignment `{other}`"))), - }; - body.insert("gridAlign".into(), json!(if first_only == Some(true) { "firstLineOnly" } else { "allLines" })); - body.insert("gridReference".into(), json!(reference)); - } - } - if let Some(v) = finite_param(p, "gyoudori", CMD)? { - let max = 100.0; - body.insert("gridGyoudori".into(), json!(v.round().clamp(0.0, max) as u32)); - } - if let Some(v) = bool_param(p, "paragraphGyoudori", CMD)? { - body.insert("paragraphGyoudori".into(), json!(v)); - } - if body.is_empty() { - return Err(bad(CMD, "nothing to set: alignment, firstLineOnly, gyoudori or paragraphGyoudori")); - } - format_paras(s, &Value::Object(body)) -} - -/// Switch a paragraph attribute of the selected paragraphs: `on`, else the opposite of the -/// first one's. -fn para_toggle(s: &mut Session, p: &Value, cmd: &str, attr: &str, get: impl Fn(&designcraft_doc::ParaProps) -> bool) -> Result { - let on = match bool_param(p, "on", cmd)? { - Some(v) => v, - None => !format_targets(s).first().is_some_and(|t| { - s.doc().ok().is_some_and(|d| { - d.doc - .text_story(t.story, t.cell) - .and_then(|st| st.paras.get(st.para_at(t.range.start))) - .is_some_and(|pf| get(&d.doc.styles.resolve_para(pf).0)) - }) - }), - }; - let mut body = serde_json::Map::new(); - body.insert(attr.to_string(), json!(on)); - format_paras(s, &Value::Object(body)) -} - -fn ruby_text<'a>(p: &'a Value, key: &str) -> Result> { - let Some(v) = p.get(key) else { return Ok(None) }; - let t = v.as_str().ok_or_else(|| bad("type.ruby", format!("{key}: expected a string")))?; - if t.chars().count() > designcraft_doc::ruby::MAX_RUBY_CHARS { - return Err(bad("type.ruby", format!("{key}: longer than {} characters", designcraft_doc::ruby::MAX_RUBY_CHARS))); - } - Ok(Some(t)) -} - -fn ruby_cmd(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::cjk_settings::AdornmentOverprint; - use designcraft_doc::ruby::{RubyAlignment, RubyOverhang, RubyParentSpacing, RubyPosition, RubyType}; - let mut body = serde_json::Map::new(); - let mut put = |attr: &str, v: Option| { - if let Some(v) = v { - body.insert(attr.into(), v); - } - }; - put("ruby", ruby_text(p, "text")?.map(|t| json!(t))); - put("rubyType", ruby_enum::(p, "type")?); - put("rubyAlignment", ruby_enum::(p, "alignment")?); - put("rubyPosition", ruby_enum::(p, "position")?); - put("rubyOverhangAmount", ruby_enum::(p, "overhang")?); - put("rubyParentSpacing", ruby_enum::(p, "parentSpacing")?); - put("rubyOverprintFill", ruby_enum::(p, "overprintFill")?); - put("rubyOverprintStroke", ruby_enum::(p, "overprintStroke")?); - put("rubyXOffset", ruby_num(p, "xOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); - put("rubyYOffset", ruby_num(p, "yOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); - put("rubyFontSize", ruby_num(p, "size", 0.1, 1296.0, 1.0)?); - put("rubyXScale", ruby_num(p, "xScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); - put("rubyYScale", ruby_num(p, "yScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); - put("rubyScalingMin", ruby_num(p, "scalingPercent", 10.0, 100.0, 100.0)?.filter(|v| !v.is_null())); - put("rubyFillTint", ruby_num(p, "fillTint", 0.0, 100.0, 100.0)?); - put("rubyStrokeTint", ruby_num(p, "strokeTint", 0.0, 100.0, 100.0)?); - put("rubyStrokeWeight", ruby_num(p, "strokeWeight", 0.0, 800.0, 1.0)?); - put( - "rubyAutoTcyDigits", - ruby_num(p, "autoTcyDigits", 0.0, 9.0, 1.0)?.filter(|v| !v.is_null()).and_then(|v| v.as_f64()).map(|v| json!(v.round() as u32)), - ); - for (key, attr) in [ - ("openTypePro", "rubyOpenTypePro"), - ("autoTcyIncludeRoman", "rubyAutoTcyIncludeRoman"), - ("autoTcyAutoScale", "rubyAutoTcyAutoScale"), - ("autoAlign", "rubyAutoAlign"), - ("autoScaling", "rubyAutoScaling"), - ] { - if let Some(v) = p.get(key) { - let b = v.as_bool().ok_or_else(|| bad("type.ruby", format!("{key}: expected true or false")))?; - put(attr, Some(json!(b))); - } - } - put("rubyFont", ruby_text(p, "font")?.map(|t| json!(t))); - put("rubyFontStyle", ruby_text(p, "fontStyle")?.map(|t| json!(t))); - for (key, attr) in [("fill", "rubyFill"), ("stroke", "rubyStroke")] { - if let Some(name) = ruby_text(p, key)? { - let known = name.is_empty() || name == designcraft_doc::color::swatch::NONE || s.doc()?.doc.swatch(name).is_some(); - if !known { - return Err(bad("type.ruby", format!("{key}: no swatch `{name}`"))); - } - put(attr, Some(json!(name))); - } - } - format_chars(s, &Value::Object(body)) -} - -/// A character fill must name a swatch of the document. -pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { - validate_char_size(attrs, command_id)?; - match &attrs.fill { - Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"), - None => Ok(()), - } -} - -/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's -/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative -/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page. -pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> { - match attrs.size { - Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => { - Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt"))) - } - _ => Ok(()), - } -} - -/// Type size limits (InDesign's, and the ones `type.ruby` already uses). -pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; -pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; - -/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that -/// line, and so the one a line-affecting attribute like leading can land on right away instead of -/// waiting for the next typed character (otherwise an empty line could never get its own leading). -fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool { - let ranges = story.para_ranges(); - ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n') -} - -pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { - let mut a = CharAttrs::default(); - if let Some(o) = attrs.as_object() { - for (k, v) in o { - a.set_json(k, v).map_err(|e| bad("type.char", e))?; - } - } - validate_char_fill(&s.doc()?.doc, &a, "type.char")?; - let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); - let targets = format_targets(s); - // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it - // also formats the mark right away, since the mark alone sets that line's height; typing there - // still needs the typing format, as new text takes the format of the run before the caret - // (the previous paragraph's mark). - if let [t] = targets.as_slice() - && t.range.is_empty() - && let Some(at) = s.doc()?.selection.text - && let Some(on_mark) = s.doc()?.doc.text_story(t.story, t.cell).filter(|st| !st.is_empty()).map(|st| empty_para_mark(st, t.range.start)) - { - let doc = s.doc()?.uid; - let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); - f.attrs.merge(&a); - f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k)); - f.cleared.extend(cleared.iter().cloned()); - if on_mark { - s.edit(|d, _| { - apply_char_attrs(d, &targets, &a, &cleared); - ok() - })?; - } - s.typing_format = Some(f); - return ok(); - } - s.edit(|d, _| { - apply_char_attrs(d, &targets, &a, &cleared); - ok() - }) -} - -/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the -/// character before it. With no such pair (story or paragraph start, a selection) this is -/// `type.char`. -fn kern_pair(s: &mut Session, p: &Value) -> Result { - const ID: &str = "type.kernPair"; - let kerning = p.get("kerning").ok_or_else(|| bad(ID, "missing `kerning`"))?; - let targets = format_targets(s); - if let [t] = targets.as_slice() - && t.range.is_empty() - && s.doc()?.selection.text.is_some() - && let Some(start) = s.doc()?.doc.text_story(t.story, t.cell).and_then(|st| pair_start(&st.text, t.range.start)) - { - let mut a = CharAttrs::default(); - a.set_json("kerning", kerning).map_err(|e| bad(ID, e))?; - let cleared = if kerning.is_null() { vec!["kerning".to_string()] } else { vec![] }; - let pair = Target { story: t.story, cell: t.cell, range: start..t.range.start }; - // Kerning chosen earlier for typing at this caret would hide the pair's value. - if typing_format(s).is_some() - && let Some(f) = &mut s.typing_format - { - f.attrs.kerning = None; - f.cleared.retain(|k| k != "kerning"); - } - return s.edit(|d, _| { - apply_char_attrs(d, &[pair], &a, &cleared); - ok() - }); - } - format_chars(s, &json!({ "kerning": kerning })) -} - -/// Byte offset of the character before `pos` when it starts a kerning pair with the character at -/// `pos`: both exist and neither is a paragraph or line break. -fn pair_start(text: &str, pos: usize) -> Option { - let is_text = |c: &char| !matches!(*c, '\n' | designcraft_doc::story::FORCED_LINE_BREAK); - text.get(pos..)?.chars().next().filter(is_text)?; - let c = text.get(..pos)?.chars().next_back().filter(is_text)?; - pos.checked_sub(c.len_utf8()) -} - -/// Character attributes `a` (and the overrides `cleared` removed) over each target's text. -pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { - for t in targets { - let r = &t.range; - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - if r.is_empty() { - if st.is_empty() { - // An empty story: its one (empty) run is the format typing takes. - if let Some(run) = st.chars.first_mut() { - run.format.over.merge(a); - st.rev = st.rev.wrapping_add(1); - } - } else if empty_para_mark(st, r.start) { - // An empty paragraph elsewhere in the story: its hidden paragraph mark is its only - // character, and the one that carries line-affecting attributes like leading. - st.format_chars(r.start..r.start + 1, |f| { - f.over.merge(a); - for k in cleared { - let _ = f.over.set_json(k, &Value::Null); - } - }); - } - continue; - } - st.format_chars(r.clone(), |f| { - f.over.merge(a); - for k in cleared { - let _ = f.over.set_json(k, &Value::Null); - } - }); - } -} - -pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { - let empty = serde_json::Map::new(); - let obj = attrs.as_object().unwrap_or(&empty); - // A rule object changes only the rule fields it names, so each paragraph gets its own attrs - // over its resolved rule; this pass validates them. - let mut a = ParaAttrs::default(); - for (k, v) in obj { - a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?; - } - super::style::check_grep_styles("type.para", &a)?; - // `null` removes the override (back to the style's value). - let cleared: Vec = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect(); - let targets = format_targets(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - if let Some(st) = d.text_story_mut(t.story, t.cell) { - st.format_paras(t.range.clone(), |p| { - let (current, _) = styles.resolve_para(p); - let mut a = a.clone(); - for (k, v) in obj { - let _ = a.set_json_over(k, v, ¤t); - } - p.para.merge(&a); - for k in &cleared { - let _ = p.para.set_json(k, &Value::Null); - } - }); - } - } - ok() - }) -} - -/// Alt+Left/Right in text: kerning at a caret, tracking of a selection, by the Kerning/Tracking -/// increment. -fn kern_step(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::Kerning; - let inc = s.doc()?.doc.settings.kerning_increment; - let inc = if inc.is_finite() { inc.clamp(1.0, 1000.0) } else { 20.0 }; - let amount = match p.get("amount").and_then(Value::as_f64) { - Some(a) => a, - None => p.get("steps").and_then(Value::as_f64).unwrap_or(1.0) * inc, - }; - if !amount.is_finite() { - return Err(bad("type.kernStep", "`amount` must be a number")); - } - let amount = amount.clamp(-11000.0, 11000.0); - // Kerning and tracking range, in 1/1000 em. - let clamp = |v: f64| v.clamp(-1000.0, 10000.0); - let targets = format_targets(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - let mut out = json!({}); - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - if t.range.is_empty() { - // Manual kerning sits on the character before the caret (it applies after it). - let pos = t.range.start; - let Some(c) = st.text.get(..pos).and_then(|before| before.chars().next_back()) else { continue }; - let at = pos - c.len_utf8(); - let para = st.paras.get(st.para_at(at)).cloned().unwrap_or_default(); - let current = styles.resolve_char(&styles.resolve_para(¶).1, st.format_after(at)).kerning; - // The font's metrics or optical value isn't known here: those start from 0. - let v = clamp(if let Kerning::Manual(v) = current { v } else { 0.0 } + amount); - st.format_chars(at..pos, |f| f.over.kerning = Some(Kerning::Manual(v))); - out = json!({"kerning": v}); - } else { - for (i, pr) in st.para_ranges().into_iter().enumerate() { - let r = pr.start.max(t.range.start)..pr.end.min(t.range.end); - let Some(para) = st.paras.get(i).cloned() else { break }; - if r.is_empty() { - continue; - } - let pc = styles.resolve_para(¶).1; - st.format_chars(r, |f| { - let v = clamp(styles.resolve_char(&pc, f).tracking + amount); - f.over.tracking = Some(v); - out = json!({"tracking": v}); - }); - } - } - } - Ok(out) - }) -} - -fn step_size(s: &mut Session, delta: f64) -> Result { - let cur = selection_attrs(s, &json!({}))?; - let size = cur["chars"]["size"].as_f64().unwrap_or(12.0); - format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)})) -} - -fn break_link(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::{CharFormat, CharProps, ParaProps}; - let para = match p.get("kind").and_then(Value::as_str).unwrap_or("paragraph") { - "paragraph" => true, - "character" => false, - k => return Err(bad("style.breakLink", format!("unknown kind `{k}`"))), - }; - let targets = format_targets(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - let r = t.range.clone(); - if para { - st.format_paras(r, |pf| { - let (pp, pc) = styles.resolve_para(pf); - pf.style = designcraft_doc::styles::NO_PARA_STYLE.into(); - pf.para = ParaProps::default().delta(&pp); - pf.chars = CharProps::default().delta(&pc); - }); - } else { - let ranges = st.para_ranges(); - for (pi, pr) in ranges.into_iter().enumerate() { - let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); - if a >= b { - continue; - } - let (_, pc) = styles.resolve_para(&st.paras[pi]); - st.format_chars(a..b, |f| { - let cp = styles.resolve_char(&pc, f); - *f = CharFormat { style: designcraft_doc::story::NO_CHAR_STYLE.into(), over: pc.delta(&cp) }; - }); - } - } - } - ok() - }) -} - -fn open_type(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::otf; - let cur = selection_attrs(s, &json!({}))?; - let mut list: Vec = - cur["chars"]["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - if let Some(tag) = p.get("feature").and_then(Value::as_str) { - if tag.is_empty() || tag.len() > 4 || !tag.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(bad("type.openType", format!("bad feature tag `{tag}`"))); - } - let on = p.get("on").and_then(Value::as_bool).unwrap_or(!otf::is_on(&list, tag)); - otf::set(&mut list, tag, on); - } - if let Some(f) = p.get("figures").and_then(Value::as_str) - && !otf::set_figures(&mut list, f) - { - return Err(bad("type.openType", format!("unknown figure style `{f}`"))); - } - if let Some(sets) = p.get("stylisticSets").and_then(Value::as_array) { - let mask = sets.iter().filter_map(Value::as_u64).filter(|n| (1..=20).contains(n)).fold(0u32, |m, n| m | 1 << (n - 1)); - otf::set_stylistic_sets(&mut list, mask); - } - format_chars(s, &json!({"otfFeatures": list}))?; - Ok(json!({"features": list, "figures": otf::figures(&list)})) -} - -/// How much of the selected text `type.selectionAttrs` reports. -const SELECTED_TEXT_CHARS: usize = 60; - -fn selected_text_preview(chars: impl Iterator) -> String { - chars - .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) - .skip_while(|c| c.is_whitespace()) - .take(SELECTED_TEXT_CHARS) - .collect() -} - -fn selection_attrs(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let targets = format_targets(s); - let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) }; - let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let pi = story.para_at(r.start); - let pf = &story.paras[pi]; - let (pp, base) = st.doc.styles.resolve_para(pf); - let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; - let cp = st.doc.styles.resolve_char(&base, &cf); - let (style_para, style_chars) = st.doc.styles.resolve_para_style(&pf.style); - let mut character_base = base.clone(); - let character_style = st.doc.styles.char_style_attrs(&cf.style); - character_base.apply(&character_style); - let char_delta = character_base.delta(&cp); - let style_keys = character_style.keys(); - let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); - // The selected text (its start, on one line), for previews such as the font menu's. - let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); - let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), - "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, - "selectedText": selected.trim()}); - let mut stories = selected_stories(s)?; - if stories.is_empty() { - stories.push(sid); - } - out["storyOptions"] = story_options_of(&st.doc, &stories); - if bool_or(p, "mixed", false) { - out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets)); - } - Ok(out) -} - -/// The story options of `stories`, as story.options names them; a value that differs between -/// the stories is null. -fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value { - let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin); - let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) }; - let (mut on, mut size) = (Some(first.on), Some(first.size)); - for om in values { - on = on.filter(|v| *v == om.on); - size = size.filter(|v| *v == om.size); - } - json!({"opticalMargin": on, "opticalMarginSize": size}) -} - -/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`. -fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec { - let mut first: Option> = None; - let mut mixed = std::collections::BTreeSet::new(); - for t in targets { - let Some(story) = doc.text_story(t.story, t.cell) else { continue }; - let a = story.para_at(t.range.start); - let b = story.para_at(t.range.end.max(t.range.start)); - for pf in story.paras.get(a..=b).unwrap_or(&[]) { - let Ok(Value::Object(v)) = serde_json::to_value(doc.styles.resolve_para(pf).0) else { continue }; - match &first { - None => first = Some(v), - Some(f) => mixed.extend(v.into_iter().filter(|(k, x)| f.get(k) != Some(x)).map(|(k, _)| k)), - } - } - } - mixed.into_iter().collect() -} - -/// The first target character, with any pending caret overrides. Keep character-style -/// inheritance separate from paragraph defaults for style creation from the selection. -pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result { - let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - let mut format = - if target.range.is_empty() { story.insert_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); - if let Some(pending) = typing_format(s) - && target.story == pending.at.story - && target.cell == pending.at.cell - && target.range == pending.at.range() - { - apply_typing_format(&mut format, pending); - } - Ok(format) -} - -/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it. -pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) { - let Some(uid) = s.active().map(|st| st.uid) else { return }; - if let Some(pending) = &mut s.typing_format - && pending.doc == uid - { - for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color] - .into_iter() - .flatten() - { - if name == from { - *name = to.to_string(); - } - } - } -} - -fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs { - CharAttrs { - fill: Some(cp.stroke.clone()), - fill_tint: Some(cp.stroke_tint), - stroke: Some(cp.fill.clone()), - stroke_tint: Some(cp.fill_tint), - ..Default::default() - } -} - -/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors. -fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result { - const ID: &str = "type.swapFillStroke"; - let targets = format_targets(s); - if let [target] = targets.as_slice() - && target.range.is_empty() - && s.doc()?.selection.text.is_some() - { - let st = s.doc()?; - let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?; - let (_, base) = st.doc.styles.resolve_para(pf); - let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?); - return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint})); - } - let mut edits = Vec::new(); - let d = &s.doc()?.doc; - for target in targets { - let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - if story.is_empty() { - let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?; - let (_, base) = d.styles.resolve_para(pf); - let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0))); - validate_char_fill(d, &attrs, ID)?; - edits.push((target, attrs)); - continue; - } - let mut runs = story.runs().peekable(); - for (pi, para) in story.para_ranges().into_iter().enumerate() { - let start = para.start.max(target.range.start); - let end = para.end.saturating_add(1).min(story.len()).min(target.range.end); - if start >= end { - continue; - } - let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?; - let (_, base) = d.styles.resolve_para(pf); - while let Some((range, format)) = runs.peek() { - if range.end <= start { - runs.next(); - continue; - } - if range.start >= end { - break; - } - let attrs = swapped_colors(&d.styles.resolve_char(&base, format)); - validate_char_fill(d, &attrs, ID)?; - edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs)); - if range.end > end { - break; - } - runs.next(); - } - } - } - s.edit(|d, _| { - for (target, attrs) in &edits { - apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]); - } - ok() - }) -} - -fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { - cf.over.merge(&f.attrs); - for k in &f.cleared { - let _ = cf.over.set_json(k, &Value::Null); - } -} - -/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or -/// off as asked, else the opposite of the first selected character. -fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { - let id = if bold { "type.bold" } else { "type.italic" }; - let cur = selection_attrs(s, &json!({}))?; - let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string(); - let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string(); - // The document's own fonts count too. - let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); - let (weight, italic) = db.traits_of(&family, &style); - let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic }); - let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) }; - let targets = format_targets(s); - // A caret: the linked style of the format there, for the next typing. - if let [t] = targets.as_slice() - && t.range.is_empty() - { - let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?; - format_chars(s, &json!({"fontStyle": linked}))?; - return Ok(json!({"on": on, "fontStyle": linked})); - } - let mut changed = 0; - let r = s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - for (pi, pr) in st.para_ranges().into_iter().enumerate() { - let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end)); - if a >= b { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, pc) = styles.resolve_para(pf); - st.format_chars(a..b, |f| { - let cp = styles.resolve_char(&pc, f); - if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic) - && linked != cp.font_style - { - f.over.font_style = Some(linked); - changed += 1; - } - }); - } - } - // No undo step for nothing. - if changed == 0 { - return Err(bad(id, "nothing to change")); - } - ok() - }); - r?; - Ok(json!({"on": on, "changed": changed})) -} - -/// One stretch of the selection: its length in bytes, the font it is set in and its features. -struct FeatureSegment { - len: usize, - font: (String, String), - applied: Vec, - ligatures: bool, -} - -/// The OpenType features of the fonts in the selection, with how much of the selection each one is -/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`, -/// `some` or `all`. -fn open_type_features(s: &mut Session, _p: &Value) -> Result { - let st = s.doc()?; - let mut segments: Vec = Vec::new(); - for Target { story: sid, cell, range: r } in format_targets(s) { - let Some(story) = st.doc.text_story(sid, cell) else { continue }; - let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() { - vec![(r.start, 1, story.format_after(r.start).clone())] - } else { - story - .runs() - .filter(|(run, _)| run.start < r.end && run.end > r.start) - .map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone())) - .collect() - }; - for (pos, len, cf) in covered { - let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; - let (_, base) = st.doc.styles.resolve_para(para); - let cp = st.doc.styles.resolve_char(&base, &cf); - segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); - } - } - if segments.is_empty() { - let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); - let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); - segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); - } - let db = designcraft_fonts::FontDb::global(); - let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); - for seg in &segments { - fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1))); - } - let total: usize = segments.iter().map(|seg| seg.len).sum::().max(1); - let state = |covered: usize| match covered { - 0 => "none", - n if n >= total => "all", - _ => "some", - }; - // Bytes the feature is applied to, and bytes whose font offers it. - let measure = |tag: &str| { - let (mut applied, mut available) = (0, 0); - for seg in &segments { - // Standard ligatures are the `ligatures` attribute, which shaping reads, unless the - // feature list turns `liga` off. - let on = designcraft_doc::otf::is_on(&seg.applied, tag) && (tag != "liga" || seg.ligatures); - if on { - applied += seg.len; - } - if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { - available += seg.len; - } - } - (state(applied), state(available)) - }; - let tags: std::collections::BTreeSet = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect(); - let mut features = Vec::new(); - for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::().is_ok())) { - let (applied, available) = measure(tag); - features.push(json!({"tag": tag, "applied": applied, "available": available})); - } - // A stylistic set's name is shared when every font that offers it gives it the same name. - let mut sets = Vec::new(); - for set in 1..=20u32 { - let tag = format!("ss{set:02}"); - if !tags.contains(&tag) { - continue; - } - let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); - let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None }; - let (applied, available) = measure(&tag); - sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); - } - let used: Vec = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect(); - Ok(json!({"fonts": used, "features": features, "stylisticSets": sets})) -} - -/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they -/// are on. Each run keeps its other features, whichever font it is set in, and the change is one -/// undo step. -fn set_features(s: &mut Session, p: &Value) -> Result { - const ID: &str = "type.feature"; - let changes: Vec<(String, bool)> = p - .get("features") - .and_then(Value::as_object) - .ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))? - .iter() - .map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?))) - .collect::>()?; - let targets: Vec = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); - if targets.is_empty() { - return Err(bad(ID, "select text")); - } - let styles = s.doc()?.doc.styles.clone(); - s.edit(|d, _| { - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - let runs: Vec<(std::ops::Range, designcraft_doc::story::CharFormat)> = - st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect(); - for (run, format) in runs { - let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue }; - let (_, base) = styles.resolve_para(¶); - let mut list = styles.resolve_char(&base, &format).otf_features; - for (tag, on) in &changes { - designcraft_doc::otf::set(&mut list, tag, *on); - } - let range = run.start.max(t.range.start)..run.end.min(t.range.end); - st.format_chars(range, |f| f.over.otf_features = Some(list.clone())); - } - } - ok() - }) -} - -/// Our own filler text (not Adobe's). -pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ -et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ -Sic erat, ut nostris ceperunt pectora verbis, mitescunt flamma solidos campos venti. Moventur iam nubes, aequora pontus, \ -dum sidera fulgent tacito per inane meatu. Arbore sub magna ludunt pueri, linquunt aurea litora fluctus. \ -Iamque tibi rursus ponet nova carmina vates, et longas umbras quaerit sub tegmine fagi. Omnia mutantur, nihil interit."; - -fn fill_placeholder(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()).or_else(|| st.selection.text.and_then(|t| t.frame)); - let sid = match frame.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()) { - Some(t) => t.story, - None => { - if let Some(f) = frame { - s.execute("object.content", &json!({"ids": [f.0], "type": "text"}))?; - } - story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? - } - }; - // Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text - // comes (so a paragraph can be styled on its own). - const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4]; - let mut text = String::new(); - let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); - let doc = s.doc()?.doc.clone(); - let mut d2 = (*doc).clone(); - let (mut paragraph, mut in_paragraph) = (0, 0); - for i in 0..400 { - text.push_str(sentences[i % sentences.len()]); - in_paragraph += 1; - if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] { - text.push('\n'); - (paragraph, in_paragraph) = (paragraph + 1, 0); - } else { - text.push(' '); - } - if i % 3 == 2 { - d2.set_story_text(sid, &text)?; - let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default()); - if cs.is_overset() { - // Trim to the last sentence that fits. - let cut = cs.overset_at.unwrap_or(text.len()).min(text.len()); - let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut); - text.truncate(keep); - break; - } - } - } - s.edit(|d, _| { - d.set_story_text(sid, text.trim_end())?; - ok() - }) -} - -pub(crate) fn format_targets_pub(s: &Session) -> Vec { - format_targets(s) -} - -/// The case-changed text of `src`, char by char (a char may map to several, e.g. ß → SS). -pub(crate) fn case_map(src: &str, case: &str) -> Vec { - let mut out = Vec::with_capacity(src.len()); - let mut prev = ' '; - let mut sentence_start = true; - for c in src.chars() { - let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}')); - let up = |c: char| c.to_uppercase().collect::(); - let low = |c: char| c.to_lowercase().collect::(); - // Title and sentence case capitalize only scripts with initial capitals (not Georgian). - let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) }; - let m = match case { - "upper" => up(c), - "lower" => low(c), - "title" => { - if word_start { - cap(c) - } else { - low(c) - } - } - _ => { - if sentence_start && c.is_alphanumeric() { - cap(c) - } else { - low(c) - } - } - }; - if c.is_alphanumeric() { - sentence_start = false; - } else if matches!(c, '.' | '!' | '?' | '\n' | '\u{2028}') { - sentence_start = true; - } - out.push(m); - prev = c; - } - out -} - -fn change_case(s: &mut Session, p: &Value) -> Result { - let case = str_param(p, "case").unwrap_or("upper").to_string(); - if !matches!(case.as_str(), "upper" | "lower" | "title" | "sentence") { - return Err(bad("type.changeCase", format!("unknown case `{case}`"))); - } - let targets = format_targets(s); - s.edit(|d, sel| { - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - let r = t.range.start.min(st.len())..t.range.end.min(st.len()); - let mapped = case_map(st.slice(r.clone()), &case); - // Replace run by run (from the end) so every run keeps its formatting. - let segs: Vec> = - st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect(); - let starts: Vec = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect(); - let mut delta: isize = 0; - for seg in segs.iter().rev() { - let old = st.slice(seg.clone()).to_string(); - let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect(); - if new != old { - delta += new.len() as isize - old.len() as isize; - st.replace(seg.clone(), &new); - } - } - if let Some(ts) = sel.text.as_mut().filter(|ts| ts.story == t.story && ts.cell == t.cell && !ts.is_caret()) { - let end = (r.end as isize + delta).max(r.start as isize) as usize; - if ts.anchor <= ts.focus { - ts.focus = end; - } else { - ts.anchor = end; - } - } - } - Ok(Value::Null) - }) -} - -#[cfg(test)] -mod open_type_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn text_selection_stays_on_utf8_boundaries() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); - let sid = r["story"].clone(); - s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 4})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 2)); - s.execute("text.select", &json!({"story": sid, "anchor": 4})).unwrap(); - s.execute("text.delete", &json!({"forward": true})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(t.story).unwrap().text, "é🙂x"); - s.execute("text.select", &json!({"story": sid, "anchor": 2})).unwrap(); - s.execute("story.setText", &json!({"story": sid, "text": "漢字"})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 0)); - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 3); - } - - #[test] - fn replacing_a_range_stays_on_utf8_boundaries() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); - let sid = r["story"].clone(); - let story = designcraft_doc::StoryId(sid.as_u64().unwrap()); - // Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing. - for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] { - let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"})); - assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}"); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x"); - } - s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x"); - // A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone. - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - s.execute("story.replaceRange", &json!({"story": sid, "start": 0, "end": 2, "text": ""})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.unwrap(); - let text = &st.doc.story(t.story).unwrap().text; - assert_eq!(text, "字🙂x"); - assert!(text.is_char_boundary(t.anchor) && text.is_char_boundary(t.focus), "caret {t:?} in {text:?}"); - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - } - - #[test] - fn selected_text_preview_stops_at_its_character_budget() { - let read = std::cell::Cell::new(0usize); - let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; - let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); - let preview = super::selected_text_preview(chars); - assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); - assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); - assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); - } - - #[test] - fn selection_attrs_report_the_selected_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s - .execute( - "frame.create", - &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue -What's new in typography this season, and why it matters to every designer"}), - ) - .unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); - s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); - let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); - assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); - s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); - } - - #[test] - fn open_type_toggles_reach_the_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Office 1/2 0"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 12})).unwrap(); - let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); - assert_eq!(r["features"], json!(["dlig"])); - let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); - assert_eq!(r["features"], json!([]), "toggles off"); - let r = s.execute("type.openType", &json!({"figures": "proportionalOldstyle", "stylisticSets": [1, 2]})).unwrap(); - assert_eq!(r["figures"], "proportionalOldstyle"); - let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); - let f: Vec<&str> = a["chars"]["otfFeatures"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); - assert!(f.contains(&"onum") && f.contains(&"ss02"), "{f:?}"); - assert!(s.execute("type.openType", &json!({"feature": "bad tag!"})).is_err()); - } - - #[test] - fn break_link_to_paragraph_and_character_styles() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Head", "para": {"spaceBefore": 6}, "chars": {"size": 24}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Em", "chars": {"fontStyle": "Italic"}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Title here"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Em"})).unwrap(); - let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); - s.execute("style.breakLink", &json!({"kind": "character"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); - s.execute("style.breakLink", &json!({"kind": "paragraph"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); - let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); - assert_eq!(after["paragraphStyle"], "[No Paragraph Style]"); - assert_eq!(after["characterStyle"], "[None]"); - assert_eq!(after["chars"], before["chars"], "same look"); - assert_eq!(after["para"], before["para"]); - } - - /// Old-style figures shape to other glyphs than the default lining ones (Source Serif 4). - #[test] - fn oldstyle_figures_change_the_glyphs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "2024"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let gids = |s: &Session| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - cs.frames[0].lines[0].glyphs.iter().map(|g| g.gid).collect::>() - }; - let lining = gids(&s); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap(); - assert_ne!(gids(&s), lining); - } - - /// A blank line (an empty paragraph) has no characters to select, but its hidden paragraph - /// mark can still take a leading change right away: otherwise there'd be no way to adjust the - /// line height of a blank line at all. - #[test] - fn leading_applies_to_an_empty_lines_hidden_paragraph_mark() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Para one\n\nPara two"; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - // The blank line between the two paragraphs: a collapsed caret, since it has no text. - let blank = text.find("\n\n").unwrap() + 1; - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - s.execute("type.char", &json!({"attrs": {"leading": {"kind": "points", "value": 40.0}}})).unwrap(); - let st = s.doc().unwrap().doc.story(sid).unwrap(); - // `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so - // check just past the mark's own one-byte run, which the mark alone occupies. - assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0))); - let d = s.doc().unwrap().doc.clone(); - let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); - let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap(); - assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed"); - } - - /// Formatting chosen on a blank line is also what gets typed there, not the format of the - /// previous paragraph's mark that new text would otherwise take. - #[test] - fn text_typed_on_a_blank_line_takes_the_format_set_there() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Para one\n\nPara two"; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let blank = text.find("\n\n").unwrap() + 1; - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); - let st = s.doc().unwrap().doc.story(sid).unwrap(); - assert_eq!(st.text, "Para one\nMiddle\nPara two"); - for b in blank..blank + "Middle\n".len() { - assert_eq!(st.format_after(b).over.size, Some(30.0), "byte {b}"); - } - assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); - } - - /// A blank line keeps its format for typing after the caret has left it: text typed on it - /// takes its paragraph mark's format, with no pending typing format. - #[test] - fn a_blank_lines_mark_format_is_typed_after_the_caret_returns() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Para one\n\nPara two"; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let blank = text.find("\n\n").unwrap() + 1; - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - assert!(s.typing_format.is_none()); - s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); - let st = s.doc().unwrap().doc.story(sid).unwrap(); - assert_eq!(st.text, "Para one\nMiddle\nPara two"); - assert_eq!(st.format_after(blank).over.size, Some(30.0)); - assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); - assert_eq!(st.format_after(2).over.size, None); - } -} - -#[cfg(test)] -mod nested_style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn nested_and_grep_styles_colour_the_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Lead", "chars": {"fill": "C=100 M=0 Y=0 K=0"}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Num", "chars": {"fill": "C=0 M=100 Y=0 K=0"}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Opening words then 2026 here"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - s.execute( - "type.para", - &json!({"attrs": { - "nestedStyles": [{"style": "Lead", "through": true, "count": 2, "until": {"kind": "words"}}], - "grepStyles": [{"style": "Num", "pattern": "\\d+"}] - }}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); - let text = &d.story(designcraft_doc::StoryId(sid)).unwrap().text; - let fill_at = |byte: usize| { - let g = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.byte == byte).unwrap(); - cs.styles[g.style as usize].fill.clone() - }; - assert_eq!(fill_at(0), "C=100 M=0 Y=0 K=0", "nested: first two words"); - assert_eq!(fill_at(text.find("words").unwrap()), "C=100 M=0 Y=0 K=0"); - assert_eq!(fill_at(text.find("then").unwrap()), "[Black]"); - assert_eq!(fill_at(text.find("2026").unwrap()), "C=0 M=100 Y=0 K=0", "GREP: digits"); - } -} - -#[cfg(test)] -mod multi_click_tests { - use serde_json::{Value, json}; - - use crate::Session; - - #[test] - fn moving_the_caret_drops_the_pending_typing_format() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; - let pending = |s: &mut Session| { - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - }; - // Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the - // same caret is in the text's own format. - let moves: Vec> = vec![ - Box::new(|s| { - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - s.execute("text.move", &json!({"dir": "left"})).unwrap(); - }), - Box::new(|s| { - s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - }), - Box::new(|s| { - s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - }), - ]; - for m in &moves { - pending(&mut s); - m(&mut s); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - assert_eq!(size_at(&s, 6), None); - s.execute("edit.undo", &json!({})).unwrap(); - } - // Without a move it applies. - pending(&mut s); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - assert_eq!(size_at(&s, 6), Some(30.0)); - // Selecting a sentence or paragraph around a point drops it too. - for id in ["text.selectSentence", "text.selectParagraph"] { - pending(&mut s); - assert!(s.typing_format.is_some()); - s.execute(id, &json!({"frame": fid, "point": [80, 80]})).unwrap(); - assert!(s.typing_format.is_none(), "{id}"); - } - } - - #[test] - fn word_selection_keeps_grapheme_clusters_whole() { - use super::word_or_spaces_at as word; - // Decomposed "café": the combining acute belongs to the word, wherever the click lands. - let s = "un cafe\u{301} noir"; - let cafe = (3, 3 + "cafe\u{301}".len()); - assert_eq!(word(s, 4), cafe); - assert_eq!(word(s, 7), cafe, "on the combining mark"); - // Devanagari with a virama (U+094D) and a dependent vowel sign. - let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x"; - let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len(); - assert_eq!(word(s, 0), (0, w)); - assert_eq!(word(s, 9), (0, w), "on the virama"); - // An emoji ZWJ sequence and a variation selector stay one character. - let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}"; - let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len()); - assert_eq!(word(s, 2), e); - assert_eq!(word(s, 6), e, "on the joiner"); - assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole"); - // Empty paragraphs select nothing. - assert_eq!(word("a\n\nb", 2), (2, 2)); - } - - /// Issue #981: holding the button after a double, triple or quadruple click and moving extends - /// the selection a word, line or paragraph at a time, from the unit first selected, either way. - #[test] - fn a_drag_after_several_clicks_extends_the_selection_by_units() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Hello brave world.\nSecond paragraph here."; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - let at = |s: &Session, b: usize| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); - json!([g.x + g.adv * 0.75, l.baseline - 3.0]) - }; - let sel = |s: &Session| s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)).unwrap(); - let extend = |s: &mut Session, b: usize, unit: &str| { - let p = at(s, b); - s.execute("text.extendTo", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); - sel(s) - }; - // Double-click "brave", drag into "world.": whole words, anchored at the start of "brave". - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); - assert_eq!(sel(&s), (7, 12)); - assert_eq!(extend(&mut s, 14, "word"), (7, 18), "forward: through \"world\""); - assert_eq!(extend(&mut s, 9, "word"), (7, 12), "back inside the word: just the word"); - assert_eq!(extend(&mut s, 1, "word"), (12, 0), "backward: from \"Hello\" to the end of \"brave\""); - assert_eq!(&text[0..12], "Hello brave"); - // Triple-click the first line, drag into the second: both lines. - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "line"})).unwrap(); - assert_eq!(sel(&s), (0, 19)); - assert_eq!(extend(&mut s, 22, "line"), (0, text.len())); - // Without `unit` a drag still moves the focus a character at a time. - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); - s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14)})).unwrap(); - assert_eq!(sel(&s).0, 7); - assert!(sel(&s).1 > 12 && sel(&s).1 < 18, "{:?}", sel(&s)); - assert!(s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14), "unit": "sentence"})).is_err()); - // A unit remembered before the text was deleted stays inside what is left. - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 14), "unit": "word"})).unwrap(); - s.execute("story.setText", &json!({"story": sid, "text": ""})).unwrap(); - s.execute("text.extendTo", &json!({"frame": fid, "point": [100, 90], "unit": "word"})).unwrap(); - assert_eq!(sel(&s), (0, 0)); - } - - #[test] - fn clicks_select_words_spaces_lines_paragraphs_and_the_story() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Hello brave world.\nSecond paragraph here."; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - // Spread point over the right half of the glyph at byte `b`. - let at = |s: &Session, b: usize| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); - json!([g.x + g.adv * 0.75, l.baseline - 3.0]) - }; - let mut select = |unit: &str, b: usize| { - let p = at(&s, b); - let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); - (r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize) - }; - assert_eq!(select("word", 1), (0, 5), "inside a word"); - assert_eq!(select("word", 4), (0, 5), "its last letter"); - assert_eq!(select("word", 5), (5, 7), "the run of spaces after it"); - assert_eq!(select("word", 18), (18, 19), "punctuation alone"); - assert_eq!(select("line", 9), (0, 19)); - assert_eq!(select("paragraph", 9), (0, 20), "with its return"); - assert_eq!(select("paragraph", 22), (20, text.len())); - assert_eq!(select("story", 9), (0, text.len())); - // A second click inside the selected word selects again rather than starting a text drag. - s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap(); - let p = at(&s, 9); - s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 19)); - s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it"); - // The older word command agrees. - let p = at(&s, 8); - s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (7, 12)); - assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err()); - assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point"); - } -} - -#[cfg(test)] -mod style_link_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn bold_and_italic_follow_the_family_and_toggle() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - let style_at = |s: &mut Session, b: usize| { - s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap(); - s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string() - }; - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); - s.execute("type.italic", &json!({})).unwrap(); - assert_eq!(style_at(&mut s, 0), "Italic"); - // Bold over italic and plain text: each run takes its own linked style. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - assert_eq!(style_at(&mut s, 0), "Bold Italic"); - assert_eq!(style_at(&mut s, 7), "Bold"); - // Again: off, back to each run's regular weight. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - assert_eq!(style_at(&mut s, 0), "Italic"); - assert_eq!(style_at(&mut s, 7), "Regular"); - // Nothing to change: an error and no undo step. - s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - assert!(s.execute("type.bold", &json!({"on": false})).is_err()); - assert_eq!(s.doc().unwrap().history.undo.len(), steps); - // At a caret the style is for what is typed next. - let end = "plain words here".len(); - s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold"); - s.execute("text.insert", &json!({"text": " now"})).unwrap(); - assert_eq!(style_at(&mut s, end + 1), "Bold"); - assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged"); - // Moving the caret forgets it. - s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0); - } -} - -#[cfg(test)] -mod text_color_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn unnamed_colours_go_to_text_when_asked() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - s.execute("selection.set", &json!({"ids": [fid]})).unwrap(); - let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); - let before = fill_of_frame(&s); - let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap(); - let red = r["swatch"].as_str().unwrap().to_string(); - assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill"); - let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); - assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str())); - // A text selection takes it without asking. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap(); - let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); - assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str()); - assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour"); - } -} - -#[cfg(test)] -mod drag_text_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn drag_selected_text_to_move_or_copy_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello brave world"})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - // Spread point of the glyph at byte `b`. - let at = |s: &Session, b: usize| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let l = &cs.frames[0].lines[0]; - let g = l.glyphs.iter().find(|g| g.byte == b).unwrap(); - json!([g.x + g.adv * 0.25, l.baseline - 3.0]) - }; - let end = |s: &Session| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let l = &cs.frames[0].lines[0]; - json!([l.end_x + 2.0, l.baseline - 3.0]) - }; - s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 12})).unwrap(); - let r = s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 8)})).unwrap(); - assert_eq!(r["dragging"], true); - s.execute("text.extendTo", &json!({"frame": fid, "point": end(&s)})).unwrap(); - s.execute("text.release", &json!({"frame": fid, "point": end(&s), "moved": true})).unwrap(); - let text = |s: &Session| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); - assert_eq!(text(&s), "Hello worldbrave "); - // Alt-drag copies (the moved text is still selected). - s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 12)})).unwrap(); - s.execute("text.release", &json!({"frame": fid, "point": at(&s, 0), "moved": true, "copy": true})).unwrap(); - assert_eq!(text(&s), "brave Hello worldbrave "); - // A click inside the selection just places the caret. - s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 2)})).unwrap(); - s.execute("text.release", &json!({"frame": fid, "point": at(&s, 2), "moved": false})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!(t.anchor, t.focus); - } -} - -#[cfg(test)] -mod nested_line_style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn first_line_takes_the_nested_line_style() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Lead", "chars": {"size": 18}})).unwrap(); - let text = "The opening line of this paragraph reads larger, and the lines after it return to the body size again."; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("type.para", &json!({"nestedLineStyles": [{"style": "Lead", "lines": 1}]})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - let lines = &cs.frames[0].lines; - assert!(lines.len() >= 3); - let size_of = |l: usize| cs.styles[lines[l].glyphs.iter().find(|g| g.visible).unwrap().style as usize].size; - assert_eq!(size_of(0), 18.0, "the first line"); - assert!( - lines[0].glyphs.iter().filter(|g| g.visible && g.adv > 0.0).all(|g| cs.styles[g.style as usize].size == 18.0), - "the whole first line" - ); - assert_eq!(size_of(1), 12.0, "the next lines"); - let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - let st = back.stories.values().find(|st| st.text.starts_with("The opening")).unwrap(); - let nl = st.paras[0].para.nested_line_styles.clone().unwrap_or_default(); - assert_eq!((nl.len(), nl.first().map(|n| n.lines)), (1, Some(1)), "IDML AllNestedLineStyles"); - assert_eq!(nl[0].style, "Lead"); - } -} - -#[cfg(test)] -mod rule_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn a_partial_rule_edit_keeps_the_other_fields() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0}); - s.execute("style.paragraph.create", &json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Ruled"})).unwrap(); - // Local: only the colour changes; the rest is inherited from the style. - s.execute("type.para", &json!({"attrs": {"ruleBelow": {"color": "[Registration]"}}})).unwrap(); - let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); - let mut want = rule.clone(); - want["color"] = json!("[Registration]"); - assert_eq!(attrs["para"]["ruleBelow"], want); - assert_eq!(attrs["para"]["ruleAbove"]["on"], false, "the other rule is untouched"); - // Style: only the weight changes. - s.execute("style.paragraph.edit", &json!({"name": "Ruled", "para": {"ruleBelow": {"weight": 1.5}}})).unwrap(); - let st = s.doc().unwrap().doc.styles.para("Ruled").cloned().unwrap(); - let below = serde_json::to_value(st.para.rule_below.unwrap()).unwrap(); - let mut want = rule.clone(); - want["weight"] = json!(1.5); - assert_eq!(below, want); - assert!(s.execute("type.para", &json!({"attrs": {"ruleBelow": {"thickness": 2}}})).is_err(), "unknown rule field"); - } - - #[test] - fn rule_above_width_follows_column_or_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Short"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let mut widths = vec![]; - for column in [true, false] { - s.execute("type.para", &json!({"ruleAbove": {"on": true, "columnWidth": column}})).unwrap(); - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - widths.push(cs.frames[0].decos[0].rect.width()); - } - assert!((widths[0] - 328.0).abs() < 1e-6, "column: {widths:?}"); - assert!(widths[1] < 100.0, "text: {widths:?}"); - } -} - -#[cfg(test)] -mod border_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn paragraph_border_and_shading_follow_each_column() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "word ".repeat(80); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 160], "content": "text", "text": text, "caret": false})).unwrap(); - s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("type.para", &json!({"borderOn": true, "borderWeights": [2, 1, 2, 1], "borderOffsets": [4, 4, 4, 4], "borderColor": "[Black]", "shadingOn": true, "shadingOffsets": [4, 4, 4, 4]})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - let decos = &cs.frames[0].decos; - let shading: Vec<_> = decos.iter().filter(|d| d.tint < 0.5).collect(); - assert_eq!(shading.len(), 2, "one shaded box per column"); - let edges = decos.len() - shading.len(); - assert_eq!(edges, 6, "top on the first part, bottom on the last, sides on both"); - let top = decos.iter().filter(|d| d.tint >= 0.5).map(|d| d.rect).fold(f64::MAX, |a, r| a.min(r.y0)); - let first_line = &cs.frames[0].lines[0]; - assert!((top - (first_line.baseline - first_line.ascent - 4.0 - 2.0)).abs() < 1e-6, "offset 4 + weight 2 above the text"); - let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - let p = &back.stories.values().find(|st| st.text.starts_with("word")).unwrap().paras[0].para; - assert_eq!( - (p.border_on, p.border_weights, p.shading_offsets), - (Some(true), Some([2.0, 1.0, 2.0, 1.0]), Some([4.0; 4])), - "IDML paragraph border" - ); - } -} - -#[cfg(test)] -mod autocorrect_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn autocorrect_fixes_the_word_before_a_space() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - let text = |s: &Session| s.doc().unwrap().doc.stories[&sid].text.clone(); - // Off by default. - for c in ["t", "e", "h", " "] { - s.execute("text.insert", &json!({"text": c})).unwrap(); - } - assert_eq!(text(&s), "teh "); - s.execute("prefs.set", &json!({"autocorrect": true})).unwrap(); - for c in ["T", "e", "h", " ", "c", "a", "t", "."] { - s.execute("text.insert", &json!({"text": c})).unwrap(); - } - assert_eq!(text(&s), "teh The cat."); - // The caret ends after the inserted punctuation. - assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "teh The cat.".len()); - } -} - -#[cfg(test)] -mod break_tests { - use serde_json::json; - - use crate::Session; - - /// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one. - #[test] - fn inserted_break_ends_its_paragraph() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap(); - s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap(); - assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree"); - assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len()); - // At the end of a paragraph the break takes the place of its return. - let at = story(&s).text.find("\nThree").unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); - s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap(); - let st = story(&s); - assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree"); - assert_eq!(st.paras.len(), 3); - st.check().unwrap(); - // Typing after the break goes to the next paragraph; deleting the break or its return - // removes both. - let at = "One\u{e011}".len(); - s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree"); - s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap(); - s.execute("text.delete", &json!({})).unwrap(); - assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree"); - s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap(); - s.execute("text.delete", &json!({"forward": true})).unwrap(); - assert_eq!(story(&s).text, "OnexTwoThree"); - } -} - -#[cfg(test)] -mod language_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn language_sets_quotes_spelling_and_hyphenation() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("type.char", &json!({"attrs": {"language": "German: 2006 Reform"}})).unwrap(); - s.execute("text.insert", &json!({"text": "\"Wort\" ist's"})).unwrap(); - let text = s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].text.clone(); - assert_eq!(text, "\u{201E}Wort\u{201C} ist\u{2019}s"); - // German words aren't English misspellings. - let st = s.execute("spelling.check", &json!({"story": sid})).unwrap(); - assert!(st.as_array().unwrap().is_empty(), "{st}"); - } - - #[test] - fn cjk_languages_have_their_quotes_and_no_english_rules() { - use super::quote_marks; - assert_eq!(quote_marks("Chinese: Simplified"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); - assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - // Other spellings of the language names take the same quotes. - assert_eq!(quote_marks("Simplified Chinese"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); - assert_eq!(quote_marks("zh_CN"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); - assert_eq!(quote_marks("Chinese (Traditional)"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("zh-TW"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("ja_JP"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] { - assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling"); - } - } - - #[test] - fn locale_coded_languages_take_their_quotes_and_rules() { - use super::quote_marks; - const GERMAN: [char; 4] = ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}']; - const DUTCH: [char; 4] = ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}']; - const GUILLEMETS: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; - const SPANISH: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}']; - const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; - const CORNERS: [char; 4] = ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']; - for (language, quotes, english) in [ - ("de_DE_2006", GERMAN, false), - ("German: 2006 Reform", GERMAN, false), - ("de_CH_2006", GUILLEMETS, false), - ("German: Swiss 2006 Reform", GUILLEMETS, false), - ("nl_NL_2005", DUTCH, false), - ("Dutch: 2005 Reform", DUTCH, false), - ("English: USA", ENGLISH, true), - ("English: UK", ENGLISH, true), - ("en_GB", ENGLISH, true), - ("Spanish: Castilian", SPANISH, false), - ("es_ES", SPANISH, false), - ("French", GUILLEMETS, false), - ("fr_FR", GUILLEMETS, false), - ("nb_NO", GUILLEMETS, false), - ("ja_JP", CORNERS, false), - ("[No Language]", ENGLISH, false), - ] { - assert_eq!(quote_marks(language), quotes, "{language}"); - assert_eq!(designcraft_compose::is_english(language), english, "{language}"); - } - } - - #[test] - fn idml_language_names_keep_their_spelling_and_pick_the_language() { - use std::io::Read; - // A stand-in for Songti SC (first in the Simplified Chinese chain; the real one when installed). - designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("Songti SC", &['直']).unwrap()); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "直"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap(); - s.execute("type.char", &json!({"attrs": {"language": "Simplified Chinese"}})).unwrap(); - let idml = designcraft_idml::export_idml(&s.doc().unwrap().doc); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml.clone())).unwrap(); - let stories: String = (0..z.len()) - .map(|i| { - let mut f = z.by_index(i).unwrap(); - let mut x = String::new(); - if f.name().starts_with("Stories/") { - f.read_to_string(&mut x).unwrap(); - } - x - }) - .collect(); - assert!(stories.contains(r#"AppliedLanguage="$ID/Simplified Chinese""#), "{stories}"); - // The name comes back as written and sets the text as Simplified Chinese. - let mut back = designcraft_idml::import_idml(&idml).unwrap(); - back.settings.glyph_fallback = true; - let st = back.stories.values().find(|st| st.text.contains('直')).unwrap(); - let (_, base) = back.styles.resolve_para(&st.paras[0]); - assert_eq!(back.styles.resolve_char(&base, st.char_format_at(0)).language, "Simplified Chinese"); - let cs = designcraft_compose::compose_story(&back, st.id, &Default::default()); - let face = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap().face; - assert_eq!(face.family, "Songti SC"); - } -} - -#[cfg(test)] -mod tcy_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn tate_chu_yoko_toggles_on_the_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "令和12年", "vertical": true})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - let at = "令和".len(); - s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at + 2})).unwrap(); - let get = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(at + 1).over.tate_chu_yoko; - s.execute("type.tateChuYoko", &json!({})).unwrap(); - assert_eq!(get(&s), Some(true)); - s.execute("type.tateChuYoko", &json!({})).unwrap(); - assert_eq!(get(&s), Some(false)); - s.execute("type.tateChuYoko", &json!({"on": true})).unwrap(); - assert_eq!(get(&s), Some(true)); - } - - #[test] - fn ruby_and_kenten_on_the_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); - s.execute("type.ruby", &json!({"text": "かんじ"})).unwrap(); - s.execute("type.kenten", &json!({})).unwrap(); - let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); - assert_eq!(f(&s).ruby.as_deref(), Some("かんじ")); - assert_eq!(f(&s).kenten, Some(true)); - s.execute("type.kenten", &json!({})).unwrap(); - s.execute("type.ruby", &json!({"text": ""})).unwrap(); - assert_eq!(f(&s).kenten, Some(false)); - assert_eq!(f(&s).ruby.as_deref(), Some("")); - } - - #[test] - fn ruby_options_are_checked_and_clamped() { - use designcraft_doc::ruby::{RubyAlignment, RubyPosition, RubyType}; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); - let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); - s.execute( - "type.ruby", - &json!({"text": "かん\u{3000}じ", "type": "perCharacter", "alignment": "oneAki", "position": "belowLeft", "size": 1e9, "xScale": -50, "scalingPercent": 5, "autoTcyDigits": 99, "fillTint": 250, "yOffset": -1e12, "fill": "[Black]"}), - ) - .unwrap(); - let a = f(&s); - assert_eq!( - (a.ruby.as_deref(), a.ruby_type, a.ruby_position), - (Some("かん\u{3000}じ"), Some(RubyType::PerCharacter), Some(RubyPosition::BelowLeft)) - ); - assert_eq!(a.ruby_font_size, Some(Some(1296.0))); - assert_eq!(a.ruby_x_scale, Some(0.01)); - assert_eq!(a.ruby_scaling_min, Some(0.1)); - assert_eq!(a.ruby_auto_tcy_digits, Some(9)); - assert_eq!(a.ruby_fill_tint, Some(Some(1.0))); - assert_eq!(a.ruby_y_offset, Some(-1000.0)); - // Options alone keep the reading. - s.execute("type.ruby", &json!({"alignment": "center"})).unwrap(); - assert_eq!((f(&s).ruby, f(&s).ruby_alignment), (a.ruby.clone(), Some(RubyAlignment::Center))); - // Refused, and nothing changes: unknown names, non-numbers, overlong text, unknown swatches. - let long = "あ".repeat(designcraft_doc::ruby::MAX_RUBY_CHARS + 1); - for p in [ - json!({"alignment": "sideways"}), - json!({"size": "big"}), - json!({"text": long}), - json!({"fill": "Nope"}), - json!({"autoAlign": 1}), - json!({"text": 5}), - ] { - assert!(s.execute("type.ruby", &p).is_err(), "{p}"); - } - assert_eq!(f(&s).ruby_alignment, Some(RubyAlignment::Center)); - assert_eq!(f(&s).ruby, a.ruby); - } - - #[test] - fn warichu_toggles_on_the_selection_and_undoes() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - let n = "注釈".len(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap(); - s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2})) - .unwrap(); - let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); - let a = f(&s); - assert_eq!(a.warichu, Some(true)); - assert_eq!(a.warichu_lines, Some(2)); - assert_eq!(a.warichu_size, Some(40.0)); - assert_eq!(a.warichu_line_spacing, Some(1.0)); - assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left)); - assert_eq!(a.warichu_chars_before_break, Some(2)); - assert_eq!(a.warichu_chars_after_break, Some(2)); - // The characters after the selection are not part of the note. - assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(f(&s).warichu, None); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(f(&s).warichu, Some(true)); - s.execute("type.warichu", &json!({})).unwrap(); - assert_eq!(f(&s).warichu, Some(false)); - } - - #[test] - fn warichu_up_and_down_move_between_the_rows() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "ABCDEFGHZ"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap(); - s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; - assert!((4..8).contains(&down), "down enters the second row: {down}"); - let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize; - assert!(up < 4, "up returns to the first row: {up}"); - s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap(); - let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; - assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row"); - } -} - -#[cfg(test)] -mod bidi_caret_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn arrow_keys_in_a_right_to_left_paragraph() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "سلام عليكم"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("type.para", &json!({"attrs": {"direction": "rightToLeft"}})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - // Left moves forward through right-to-left text, Right back. - let p = s.execute("text.move", &json!({"dir": "left"})).unwrap()["pos"].as_u64().unwrap(); - assert_eq!(p, 'س'.len_utf8() as u64); - let p = s.execute("text.move", &json!({"dir": "right"})).unwrap()["pos"].as_u64().unwrap(); - assert_eq!(p, 0); - // By words too. - let p = s.execute("text.move", &json!({"dir": "left", "word": true})).unwrap()["pos"].as_u64().unwrap(); - assert!(p >= "سلام".len() as u64, "{p}"); - } -} - -#[cfg(test)] -mod story_options_tests { - use super::*; - - #[test] - fn malformed_explicit_story_targets_do_not_change_the_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Selected"})).unwrap(); - let sid = StoryId(r["story"].as_u64().unwrap()); - for target in ["story", "frame"] { - for invalid in [json!("invalid"), json!(-1), json!(1.5), json!(true), json!({}), json!([]), json!(999_999)] { - let mut params = json!({"opticalMargin": true}); - params[target] = invalid; - assert!(s.execute("story.options", ¶ms).is_err(), "{params}"); - assert!(!story(&s, sid).on, "{params} changed the selected story"); - } - } - // Selection fallback still works when neither explicit target is supplied. - s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); - assert!(story(&s, sid).on); - s.execute("story.options", &json!({"frame": r["id"], "opticalMargin": false})).unwrap(); - assert!(!story(&s, sid).on); - } - - #[test] - fn story_options_treat_null_targets_as_absent_and_validate_both_explicit_ids() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Explicit"})).unwrap(); - let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Selected"})).unwrap(); - let aid = StoryId(a["story"].as_u64().unwrap()); - let bid = StoryId(b["story"].as_u64().unwrap()); - for targets in [json!({"story": null}), json!({"frame": null}), json!({"story": null, "frame": null})] { - let mut p = targets; - p["opticalMargin"] = json!(true); - s.execute("story.options", &p).unwrap(); - assert!(story(&s, bid).on); - assert!(!story(&s, aid).on); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!story(&s, bid).on); - } - for targets in [json!({"story": null, "frame": a["id"]}), json!({"story": a["story"], "frame": null})] { - let mut p = targets; - p["opticalMargin"] = json!(true); - s.execute("story.options", &p).unwrap(); - assert!(story(&s, aid).on); - assert!(!story(&s, bid).on); - s.execute("edit.undo", &json!({})).unwrap(); - } - // Even a frame ignored by story precedence must be valid when explicitly supplied. - let non_text = s.execute("frame.create", &json!({"rect": [400, 0, 500, 100]})).unwrap(); - for (targets, message) in [ - (json!({"frame": 999_999}), "no such frame 999999".to_string()), - (json!({"frame": non_text["id"]}), format!("frame {} holds no text", non_text["id"])), - (json!({"story": 999_999}), "no such story 999999".to_string()), - (json!({"story": a["story"], "frame": 999_999}), "no such frame 999999".to_string()), - ] { - let mut p = targets; - p["opticalMargin"] = json!(true); - assert!(s.execute("story.options", &p).unwrap_err().to_string().contains(&message), "{p}: expected {message}"); - assert!(!story(&s, aid).on); - assert!(!story(&s, bid).on); - } - } - - fn first_x(s: &Session, sid: StoryId) -> f64 { - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - cs.frames[0].lines[0].glyphs[0].x - } - - fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin { - s.doc().unwrap().doc.story(sid).unwrap().optical_margin - } - - #[test] - fn story_options_set_optical_margin_with_one_undo_step_each() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); - let sid = StoryId(r["story"].as_u64().unwrap()); - let flush = first_x(&s, sid); - // The selected frame's story. - s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); - assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); - let hung = first_x(&s, sid); - assert!(hung < flush - 1.0, "{hung} {flush}"); - s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap(); - assert_eq!(story(&s, sid).size, 24.0); - assert!(first_x(&s, sid) < hung - 1.0); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!story(&s, sid).on); - assert_eq!(first_x(&s, sid), flush); - // Hostile sizes. - s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap(); - assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE); - for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] { - assert!(s.execute("story.options", &bad).is_err(), "{bad}"); - } - } - - #[test] - fn ignore_optical_margin_through_type_para_and_paragraph_styles() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); - let sid = StoryId(r["story"].as_u64().unwrap()); - let flush = first_x(&s, sid); - s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); - assert!(first_x(&s, sid) < flush - 1.0); - let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin; - s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap(); - assert_eq!(ignored(&s), Some(true)); - assert_eq!(first_x(&s, sid), flush); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(ignored(&s), None); - s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap(); - let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin; - assert!(resolved(&s)); - s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap(); - assert!(!resolved(&s)); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(resolved(&s)); - s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap(); - assert_eq!(first_x(&s, sid), flush); - } - - #[test] - fn selection_attrs_report_the_story_options_with_differences_as_null() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap(); - let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap(); - let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone(); - assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0})); - s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap(); - assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story"); - s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); - assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null})); - s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap(); - assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0})); - } -} - -#[cfg(test)] -mod arabic_tests { - use super::*; - #[test] - fn arabic_story_direction_command_validates_and_supports_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "abc"})).unwrap(); - let sid = StoryId(r["story"].as_u64().unwrap()); - let before = s.cache.get(&s.doc().unwrap().doc, sid, None); - s.execute("story.setDirection", &json!({"story": sid.0, "direction": "rightToLeft"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::RightToLeft); - let after = s.cache.get(&s.doc().unwrap().doc, sid, None); - assert!(!std::sync::Arc::ptr_eq(&before, &after)); - assert!(s.execute("story.setDirection", &json!({"story": sid.0, "direction": "unknown"})).is_err()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight); - } -} - -#[cfg(test)] -mod korean_breaks_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn korean_character_breaks_are_a_paragraph_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true)); - s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true)); - } -} - -#[cfg(test)] -mod story_direction_tests { - use designcraft_doc::{ItemId, StoryId}; - use serde_json::json; - - use crate::Session; - - fn frame(s: &mut Session, rect: [f64; 4], text: &str, vertical: bool) -> (ItemId, StoryId) { - let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false, "vertical": vertical})).unwrap(); - (ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap())) - } - - /// Which frames of the story are composed vertically, in thread order. - fn composed(s: &Session, sid: StoryId) -> Vec { - s.cache.get(&s.doc().unwrap().doc, sid, None).frames.iter().map(|f| f.vertical).collect() - } - - #[test] - fn story_direction_turns_every_frame_of_the_story() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", false); - let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); - s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); - assert_eq!(composed(&s, sid), [false, false]); - // From the second frame selected, through Type ▸ Story Direction. - s.execute("selection.set", &json!({"ids": [b.0]})).unwrap(); - s.execute("type.storyDirection", &json!({"vertical": true})).unwrap(); - assert_eq!(composed(&s, sid), [true, true]); - // From a caret in the text. - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("type.storyDirection", &json!({"vertical": false})).unwrap(); - assert_eq!(composed(&s, sid), [false, false]); - // The frame option sets the frame's story. - s.execute("object.textFrameOptions", &json!({"ids": [a.0], "vertical": true})).unwrap(); - assert_eq!(composed(&s, sid), [true, true]); - // Undo puts it back. - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(composed(&s, sid), [false, false]); - } - - #[test] - fn a_frame_threaded_onto_a_vertical_story_turns_vertical() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", true); - let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); - let c = s.execute("frame.create", &json!({"rect": [450, 72, 550, 400]})).unwrap(); - let c = ItemId(c["id"].as_u64().unwrap()); - s.edit(|d, _| { - d.thread(a, b)?; - Ok(d.thread(b, c)?) - }) - .unwrap(); - assert_eq!(composed(&s, sid), [true, true, true]); - } -} - -#[cfg(test)] -mod vertical_caret_tests { - use designcraft_doc::StoryId; - use serde_json::json; - - use crate::Session; - - /// A story set in three or more lines; returns its id. - fn story(s: &mut Session, vertical: bool) -> StoryId { - s.execute("file.new", &json!({})).unwrap(); - let text = "一二三四五六七八九十".repeat(8); - let rect = if vertical { [72, 72, 200, 400] } else { [72, 72, 400, 200] }; - let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "vertical": vertical})).unwrap(); - StoryId(r["story"].as_u64().unwrap()) - } - - fn mv(s: &mut Session, dir: &str, extend: bool, word: bool) -> usize { - s.execute("text.move", &json!({"dir": dir, "extend": extend, "word": word})).unwrap()["pos"].as_u64().unwrap() as usize - } - - /// (line index, position along the line) of the caret at `pos`. - fn place(s: &Session, sid: StoryId, pos: usize) -> (usize, f64) { - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - let (_, x, baseline, _, _) = crate::compose::caret(&cs, pos).unwrap(); - let line = cs.frames[0].lines.iter().position(|l| (l.baseline - baseline).abs() < 0.01).unwrap(); - (line, x) - } - - #[test] - fn arrow_keys_follow_vertical_lines() { - let mut s = Session::new(); - let sid = story(&mut s, true); - assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines.len() >= 3); - let c = '一'.len_utf8(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); - // Down and Up move along the line, a character at a time. - assert_eq!(mv(&mut s, "down", false, false), 4 * c); - assert_eq!(mv(&mut s, "up", false, false), 3 * c); - // Left moves to the next line (lines follow each other right to left), keeping the - // position along the line; Right comes back. - let (line, x) = place(&s, sid, 3 * c); - let next = mv(&mut s, "left", false, false); - let (nline, nx) = place(&s, sid, next); - assert_eq!(nline, line + 1); - assert!((nx - x).abs() < 0.5, "{x} {nx}"); - assert_eq!(mv(&mut s, "right", false, false), 3 * c); - // Shift extends. - assert_eq!(mv(&mut s, "down", true, false), 4 * c); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (3 * c, 4 * c)); - let p = mv(&mut s, "left", true, false); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (3 * c, p)); - assert_eq!(place(&s, sid, p).0, line + 1); - // With a selection, Up collapses it to its start. - assert_eq!(mv(&mut s, "up", false, false), 3 * c); - // By words: Alt+Down is the vertical Alt+Right. - let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone(); - assert_eq!(mv(&mut s, "down", false, true), super::next_word(&text, 3 * c)); - } - - #[test] - fn arrow_keys_in_horizontal_lines() { - let mut s = Session::new(); - let sid = story(&mut s, false); - let c = '一'.len_utf8(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); - assert_eq!(mv(&mut s, "right", false, false), 4 * c); - assert_eq!(mv(&mut s, "left", false, false), 3 * c); - let (line, x) = place(&s, sid, 3 * c); - let next = mv(&mut s, "down", false, false); - let (nline, nx) = place(&s, sid, next); - assert_eq!(nline, line + 1); - assert!((nx - x).abs() < 0.5, "{x} {nx}"); - assert_eq!(mv(&mut s, "up", false, false), 3 * c); - } -} - -#[cfg(test)] -mod story_query_contract_tests { - use serde_json::json; - #[test] - fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story")); - let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap(); - assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], ""); - assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], ""); - s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap(); - assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); - } - - #[test] - fn kenten_shatai_auto_tcy_and_grid_settings_take_checked_values() { - use designcraft_doc::cjk::CharacterAlignment; - use designcraft_doc::cjk_settings::{KentenKind, KentenPosition}; - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦書き12年", "vertical": true})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": "縦書き".len()})).unwrap(); - let chars = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].char_format_at(1).over.clone(); - let para = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].paras[0].para.clone(); - // Any setting turns kenten on; values are clamped and text is capped. - let long = "★".repeat(10_000); - s.execute( - "type.kenten", - &json!({"kind": "custom", "character": long, "size": 1e300, "distance": -1e300, "xScale": 0, "fillTint": 250, "position": "belowLeft"}), - ) - .unwrap(); - let a = chars(&s); - assert_eq!((a.kenten, a.kenten_kind, a.kenten_position), (Some(true), Some(KentenKind::Custom), Some(KentenPosition::BelowLeft))); - assert_eq!(a.kenten_character.as_ref().map(|c| c.chars().count()), Some(designcraft_doc::cjk_settings::KENTEN_MARK_MAX_CHARS)); - assert_eq!( - (a.kenten_size, a.kenten_distance, a.kenten_x_scale, a.kenten_fill_tint), - (Some(Some(1296.0)), Some(-1296.0), Some(0.01), Some(Some(1.0))) - ); - // Bad values change nothing. - for bad in [json!({"kind": "sparkle"}), json!({"size": "big"}), json!({"character": 5}), json!({"on": "yes"}), json!({"overprintFill": 1})] { - assert!(s.execute("type.kenten", &bad).is_err(), "{bad}"); - } - assert_eq!(chars(&s), a); - s.execute("type.kenten", &json!({"on": false})).unwrap(); - assert_eq!((chars(&s).kenten, chars(&s).kenten_kind), (Some(false), Some(KentenKind::Custom)), "off keeps the settings"); - // Shatai. - s.execute("type.shatai", &json!({"magnification": 1e9, "angle": -30, "adjustRotation": true})).unwrap(); - let a = chars(&s); - assert_eq!((a.shatai_magnification, a.shatai_angle, a.shatai_adjust_rotation), (Some(90.0), Some(150.0), Some(true))); - assert!(s.execute("type.shatai", &json!({})).is_err()); - assert!(s.execute("type.shatai", &json!({"angle": [45]})).is_err()); - // Paragraph settings. - s.execute("type.autoTateChuYoko", &json!({"digits": 1e12, "includeRoman": true})).unwrap(); - assert_eq!((para(&s).auto_tcy, para(&s).auto_tcy_include_roman), (Some(designcraft_compose::shape::AUTO_TCY_MAX), Some(true))); - s.execute("type.gridSettings", &json!({"alignment": "emCenter", "firstLineOnly": true, "gyoudori": 1e12, "paragraphGyoudori": true})) - .unwrap(); - let p = para(&s); - assert_eq!((p.grid_align, p.grid_reference), (Some(designcraft_doc::GridAlign::FirstLineOnly), Some(CharacterAlignment::EmCenter))); - assert_eq!((p.grid_gyoudori, p.paragraph_gyoudori), (Some(100), Some(true))); - assert!(s.execute("type.gridSettings", &json!({"alignment": "middle"})).is_err()); - s.execute("type.gridSettings", &json!({"alignment": "none"})).unwrap(); - assert_eq!(para(&s).grid_align, Some(designcraft_doc::GridAlign::None)); - s.execute("type.rotateRoman", &json!({})).unwrap(); - assert_eq!(para(&s).rotate_roman, Some(true)); - s.execute("type.rotateRoman", &json!({})).unwrap(); - assert_eq!(para(&s).rotate_roman, Some(false)); - s.execute("type.romanWordBreak", &json!({"on": true})).unwrap(); - assert_eq!(para(&s).roman_word_break, Some(true)); - // The story still composes with every setting at its limit. - let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); - assert!(!cs.frames.is_empty()); - } -} - -#[cfg(test)] -mod sentence_selection_tests { - use super::{para_bounds, sentence_bounds}; - - fn sentence(s: &str, at: &str) -> String { - let i = s.find(at).unwrap(); - let (a, b) = sentence_bounds(s, i); - s[a..b].to_string() - } - - #[test] - fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { - let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; - assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); - assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); - assert_eq!(sentence(s, "Twice"), "(Twice!) "); - assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); - assert_eq!(sentence(s, "fine"), "fine."); - assert_eq!(sentence(s, "one"), "Next one. "); - assert_eq!(sentence(s, "Last"), "Last"); - // The caret at the very end of a paragraph is in its last sentence. - let end = s.find('\n').unwrap(); - assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); - } - - #[test] - fn cjk_full_stops_end_sentences_without_a_space() { - let s = "これは文です。次の文!最後"; - assert_eq!(sentence(s, "次"), "次の文!"); - assert_eq!(sentence(s, "最後"), "最後"); - } - - #[test] - fn paragraphs_stop_at_returns() { - let s = "One.\nTwo two.\n"; - assert_eq!(para_bounds(s, 6), (5, 13)); - assert_eq!(para_bounds(s, 0), (0, 4)); - assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); - } -} - -#[cfg(test)] -mod reserve_tests { - use super::*; - - #[test] - fn failed_reservation_rolls_back_the_edit_even_after_local_text_was_removed() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let result = s.execute("frame.create", &json!({"rect":[36,36,300,300],"content":"text","text":"Keep this text."})).unwrap(); - let sid = StoryId(result["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story":sid.0,"anchor":1,"focus":4})).unwrap(); - let before = s.doc().unwrap().clone(); - assert!(before.selection.text.is_some_and(|t| !t.range().is_empty())); - let journal = s.journal.clone(); - let error = s - .edit(|d, sel| { - let st = d.story_mut(sid).unwrap(); - st.format_chars(5..9, |f| f.over.change = Some(designcraft_doc::ChangeMark::Inserted)); - let at = super::super::changes::mark_deleted(st, 0..9); - assert_eq!(at, 5); - assert_eq!(st.text, "Keep text."); - assert_eq!(st.format_after(0).over.change, Some(designcraft_doc::ChangeMark::Deleted)); - sel.text = None; - reserve_command_text(st, usize::MAX)?; - ok() - }) - .unwrap_err(); - assert!(error.to_string().contains("could not reserve text insertion")); - let after = s.doc().unwrap(); - assert!(std::sync::Arc::ptr_eq(&after.doc, &before.doc)); - assert_eq!(after.selection, before.selection); - assert_eq!(after.revision, before.revision); - assert_eq!(after.history.undo.len(), before.history.undo.len()); - assert_eq!(after.history.redo.len(), before.history.redo.len()); - assert_eq!(s.journal, journal); - } -} - -#[cfg(test)] -mod open_type_features_tests { - use serde_json::{Value, json}; - - use crate::Session; - - fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value { - v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null) - } - - #[test] - fn the_selected_fonts_features_are_listed_with_their_state() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}"); - assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}"); - assert_eq!(feature(&v, "kern")["available"], "all", "{v}"); - } - - #[test] - fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap(); - let story = r["story"].clone(); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); - s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap(); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}"); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}"); - s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap(); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); - } - - #[test] - fn standard_ligatures_follow_the_ligatures_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "office"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 6})).unwrap(); - s.execute("type.char", &json!({"attrs": {"ligatures": false}})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "liga")["applied"], "none", "Character › Ligatures off: {v}"); - s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); - s.execute("type.feature", &json!({"features": {"liga": false}})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "liga")["applied"], "none", "-liga in the feature list: {v}"); - } -} - -#[cfg(test)] -mod gradient_text_tests { - use serde_json::json; - - /// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns - /// `x0..x1`. - fn ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { - let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); - for x in x0..x1 { - for y in 0..img.height { - let p = img.pixel(x, y); - if p[1] < 100 { - (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); - } - } - } - assert!(n > 50.0, "no ink in {x0}..{x1}"); - (r / n, b / n) - } - - /// Red ink at the frame's left, blue at its right. - fn check(img: &designcraft_render::Rendered, what: &str) { - let (lr, lb) = ink(img, 0, 60); - let (rr, rb) = ink(img, 200, 300); - assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); - assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); - } - - #[test] - fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); - // A gradient swatch (an off-page object's unnamed one), red → blue. - let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); - s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) - .unwrap(); - let id = designcraft_doc::ItemId(shape.as_u64().unwrap()); - let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap(); - s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen"); - // The PDF, placed back and drawn: the same gradient. - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "text.pdf", "x": 0, "y": 0, "width": 300})) - .unwrap(); - let d = p.doc().unwrap().doc.clone(); - check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); - } - - /// The columns the red → blue ink spans (black table edges left out). - fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) { - let cols: Vec = (0..img.width) - .filter(|&x| { - (0..img.height).any(|y| { - let p = img.pixel(x, y); - p[1] < 100 && p[0].max(p[2]) > 100 - }) - }) - .collect(); - (*cols.first().unwrap(), *cols.last().unwrap()) - } - - /// Mean red and blue of the red → blue ink in columns `x0..x1`. - fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { - let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); - for x in x0..x1 { - for y in 0..img.height { - let p = img.pixel(x, y); - if p[1] < 100 && p[0].max(p[2]) > 100 { - (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); - } - } - } - assert!(n > 20.0, "no ink in {x0}..{x1}"); - (r / n, b / n) - } - - #[test] - fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); - let g = s - .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) - .unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); - // A third of the frame's width: the cell's text, red → blue. - let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone(); - let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; - let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap()); - let text = &mut t.cell_mut(0, 0).unwrap().text; - text.insert(0, "HHHH"); - text.format_chars(0..4, |f| { - f.over.fill = Some(g.clone()); - f.over.size = Some(24.0); - }); - let mut rr = designcraft_render::Renderer::new(); - let cache = designcraft_compose::Cache::new(); - let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); - let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300})) - .unwrap(); - let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap(); - let (x0, x1) = ink_span(&screen); - assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}"); - let q = (x1 - x0) / 4; - for (what, img) in [("screen", &screen), ("pdf", &placed)] { - let (lr, lb) = coloured_ink(img, x0, x0 + q); - let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1); - assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); - assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); - } - } -} - -#[cfg(test)] -mod placeholder_tests { - use serde_json::json; - - use crate::Session; - - /// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one - /// paragraph, and the filler ran sentences together: "interit.Ovid"). - #[test] - fn placeholder_text_comes_in_paragraphs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap(); - s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap(); - let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); - let text = st["text"].as_str().unwrap(); - let paragraphs: Vec<&str> = text.split('\n').collect(); - assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len()); - for p in ¶graphs { - assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}"); - } - assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}"); - assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]); - assert!(text.len() > 1500, "the frame is filled: {}", text.len()); - } -} - -#[cfg(test)] -mod kern_pair_tests { - use designcraft_doc::{Kerning, StoryId}; - use serde_json::json; - - use crate::Session; - - fn avatar() -> (Session, StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AVATAR"})).unwrap(); - (s, StoryId(r["story"].as_u64().unwrap())) - } - - fn kern_at(s: &Session, story: StoryId, pos: usize) -> Option { - s.doc().unwrap().doc.story(story).unwrap().format_after(pos).over.kerning - } - - #[test] - fn kerning_at_a_caret_kerns_the_pair_in_one_undo_step() { - let (mut s, sid) = avatar(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); - assert_eq!(kern_at(&s, sid, 0), Some(Kerning::Manual(-80.0)), "A, before the caret, spaces the A|V pair"); - assert_eq!(kern_at(&s, sid, 1), None); - assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); - let shown = s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["kerning"].clone(); - assert_eq!(shown, json!({"kind": "manual", "value": -80.0}), "the caret shows the pair's kerning"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(kern_at(&s, sid, 0), None); - } - - #[test] - fn kerning_a_selection_or_at_the_story_start_works_like_type_char() { - let (mut s, sid) = avatar(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 2, "focus": 4})).unwrap(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": 20}})).unwrap(); - let kerned: Vec<_> = (0..6).map(|i| kern_at(&s, sid, i)).collect(); - let m = Some(Kerning::Manual(20.0)); - assert_eq!(kerned, [None, None, m, m, None, None]); - // No character before the caret: the value is for the next typing. - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "none"}})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps); - assert_eq!(kern_at(&s, sid, 0), None); - s.execute("text.insert", &json!({"text": "X"})).unwrap(); - assert_eq!(kern_at(&s, sid, 0), Some(Kerning::None)); - } - - #[test] - fn kerning_at_the_end_of_a_paragraph_or_story_leaves_the_text_alone() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AV\nTA"})).unwrap(); - let sid = StoryId(r["story"].as_u64().unwrap()); - for caret in [2, 5] { - s.execute("text.select", &json!({"story": sid.0, "anchor": caret, "focus": caret})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no pair at {caret}: nothing is edited"); - assert_eq!((kern_at(&s, sid, 1), kern_at(&s, sid, 4)), (None, None), "caret {caret}"); - } - } -} - -#[cfg(test)] -mod pick_up_format_tests { - use designcraft_doc::ItemId; - use serde_json::json; - - use crate::Session; - - /// Typefool's video (12:04): select body text, Eyedropper, click the third header: the body - /// text takes the header's font, size and paragraph settings. DesignCraft copied the frame's - /// fill and stroke instead. - #[test] - fn the_eyedropper_copies_text_formatting_to_selected_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let head = s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Innovative Design"})).unwrap(); - s.execute("text.select", &json!({"story": head["story"], "anchor": 0, "focus": 17})).unwrap(); - s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold", "size": 18, "tracking": 20}})).unwrap(); - s.execute("type.para", &json!({"attrs": {"align": "center", "spaceAfter": 6}})).unwrap(); - let body = s.execute("frame.create", &json!({"rect": [36, 200, 400, 300], "content": "text", "text": "Body copy here.\nSecond."})).unwrap(); - s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); - // Click the header's middle (the frame's text starts at its top left). - let r = s.execute("type.pickUpFormat", &json!({"frame": head["id"], "point": [100, 50]})).unwrap(); - assert!(r.is_object(), "{r}"); - s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); - let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); - assert_eq!((&a["chars"]["fontStyle"], &a["chars"]["size"], &a["chars"]["tracking"]), (&json!("Bold"), &json!(18.0), &json!(20.0)), "{a}"); - assert_eq!((&a["para"]["align"], &a["para"]["spaceAfter"]), (&json!("center"), &json!(6.0))); - // Only the selected characters change; the second paragraph keeps its own. - s.execute("text.select", &json!({"story": body["story"], "anchor": 6, "focus": 10})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], json!(12.0)); - s.execute("text.select", &json!({"story": body["story"], "anchor": 17, "focus": 20})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], json!("left")); - // Not a text frame, or nothing selected: refused. - let rect = s.execute("frame.create", &json!({"rect": [500, 500, 520, 520]})).unwrap()["id"].as_u64().unwrap(); - assert!(s.execute("type.pickUpFormat", &json!({"frame": rect, "point": [510, 510]})).is_err()); - let _ = ItemId(rect); - } -} - -#[cfg(test)] -mod size_bounds_tests { - use serde_json::json; - - use crate::Session; - - /// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative - /// size was accepted the same way, and 1e9 pt asked the composer for a line a million times - /// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt. - #[test] - fn a_type_size_outside_the_range_is_refused() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); - let select = |s: &mut Session| { - s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); - }; - for size in [0.0, -12.0, 0.05, 1297.0, 1e9] { - select(&mut s); - let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string(); - assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}"); - } - // The sizes in range still apply, including the ends. - for size in [0.1, 12.0, 1296.0] { - select(&mut s); - s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}")); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size)); - } - // And the styles that carry character attributes are checked the same way. - for (command, params) in [ - ("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})), - ("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})), - ] { - let e = s.execute(command, ¶ms).expect_err("refused").to_string(); - assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}"); - } - } -} diff --git a/crates/engine/src/cmd/text/asian.rs b/crates/engine/src/cmd/text/asian.rs new file mode 100644 index 0000000000..93dcfd7bf8 --- /dev/null +++ b/crates/engine/src/cmd/text/asian.rs @@ -0,0 +1,451 @@ +use super::format::{char_flag_on, format_chars, format_paras, format_targets}; +use super::*; + +pub(super) fn warichu_cmd(s: &mut Session, p: &Value) -> Result { + let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some()); + let on = match p.get("on").and_then(Value::as_bool) { + Some(v) => v, + None if has_opts => true, + None => !format_targets(s).first().is_some_and(|t| { + s.doc() + .ok() + .and_then(|d| d.doc.text_story(t.story, t.cell)) + .is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true)) + }), + }; + let mut body = serde_json::Map::new(); + body.insert("warichu".into(), json!(on)); + if on { + if let Some(v) = p.get("lines").and_then(Value::as_u64) { + body.insert("warichuLines".into(), json!(v.clamp(2, 16))); + } + if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) { + body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0))); + } + if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) { + body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0))); + } + if let Some(v) = p.get("align").and_then(Value::as_str) { + body.insert("warichuAlignment".into(), json!(v)); + } + if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) { + body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100))); + } + if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) { + body.insert("warichuCharsAfterBreak".into(), json!(v.min(100))); + } + } + format_chars(s, &Value::Object(body)) +} + +// Kenten, shatai, auto tate-chu-yoko and grid settings: validated settings for the CJK commands. + +/// Longest font, style or swatch name taken from parameters, in characters. +const NAME_MAX_CHARS: usize = 256; + +/// A number parameter: absent or `null` → `None`; anything but a finite number is an error. +fn finite_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))), + } +} + +/// A text parameter, cut to `max` characters: absent or `null` → `None`; not text is an error. +fn text_param(p: &Value, key: &str, cmd: &str, max: usize) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(t)) => Ok(Some(t.chars().take(max).collect())), + Some(_) => Err(bad(cmd, format!("`{key}` must be text"))), + } +} + +/// A boolean parameter: absent or `null` → `None`; not a boolean is an error. +pub(super) fn bool_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))), + } +} + +/// An enumeration parameter checked against `T`, as its JSON value. +fn enum_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let t: T = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?; + serde_json::to_value(t).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))) + } + } +} + +/// A ruby enum parameter, checked against its type. +fn ruby_enum(p: &Value, key: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + serde_json::from_value::(v.clone()).map_err(|e| bad("type.ruby", format!("{key}: {e}")))?; + Ok(Some(v.clone())) +} + +/// A ruby number parameter clamped to `lo..=hi`, then divided by `div` (100 for percentages); +/// `null` gives `Value::Null` (automatic). +fn ruby_num(p: &Value, key: &str, lo: f64, hi: f64, div: f64) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(Value::Null)), + Some(v) => { + let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("type.ruby", format!("{key}: expected a finite number")))?; + Ok(Some(json!(x.clamp(lo, hi) / div))) + } + } +} + +pub(super) fn kenten_cmd(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::cjk_settings::{AdornmentOverprint, KENTEN_MARK_MAX_CHARS, KentenAlignment, KentenKind, KentenPosition}; + const CMD: &str = "type.kenten"; + let mut body = serde_json::Map::new(); + let mut set = |k: &str, v: Value| { + body.insert(k.to_string(), v); + }; + // Validate every setting first, so a bad one changes nothing. + if let Some(v) = enum_param::(p, "kind", CMD)? { + // A preset stores its mark as the character too, as IDML import does, so a custom + // character left from before isn't drawn in its place. + if let Some(mark) = serde_json::from_value::(v.clone()).ok().and_then(KentenKind::mark) { + set("kentenCharacter", json!(mark)); + } + set("kentenKind", v); + } + if let Some(c) = text_param(p, "character", CMD, KENTEN_MARK_MAX_CHARS)? { + set("kentenCharacter", json!(c.chars().filter(|c| !c.is_control()).collect::())); + } + for (key, attr) in [("font", "kentenFont"), ("fontStyle", "kentenFontStyle"), ("fill", "kentenFill"), ("stroke", "kentenStroke")] { + if let Some(t) = text_param(p, key, CMD, NAME_MAX_CHARS)? { + set(attr, json!(t)); + } + } + if let Some(v) = finite_param(p, "size", CMD)? { + set("kentenSize", json!(v.clamp(0.1, 1296.0))); + } + if let Some(v) = finite_param(p, "distance", CMD)? { + set("kentenDistance", json!(v.clamp(-1296.0, 1296.0))); + } + for (key, attr) in [("xScale", "kentenXScale"), ("yScale", "kentenYScale")] { + if let Some(v) = finite_param(p, key, CMD)? { + set(attr, json!(v.clamp(1.0, 1000.0) / 100.0)); + } + } + for (key, attr) in [("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint")] { + if let Some(v) = finite_param(p, key, CMD)? { + set(attr, json!(v.clamp(0.0, 100.0) / 100.0)); + } + } + if let Some(v) = finite_param(p, "strokeWeight", CMD)? { + set("kentenStrokeWeight", json!(v.clamp(0.0, 1000.0))); + } + if let Some(v) = enum_param::(p, "position", CMD)? { + set("kentenPosition", v); + } + if let Some(v) = enum_param::(p, "alignment", CMD)? { + set("kentenAlignment", v); + } + for (key, attr) in [("overprintFill", "kentenOverprintFill"), ("overprintStroke", "kentenOverprintStroke")] { + if let Some(v) = enum_param::(p, key, CMD)? { + set(attr, v); + } + } + // `null` returns a setting to the style's (size: half the text size by default). + for (key, attr) in + [("size", "kentenSize"), ("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint"), ("strokeWeight", "kentenStrokeWeight")] + { + if p.get(key).is_some_and(Value::is_null) { + set(attr, Value::Null); + } + } + let on = match bool_param(p, "on", CMD)? { + Some(v) => v, + None if !body.is_empty() => true, + None => !char_flag_on(s, |f| f.kenten), + }; + if !on { + // Off keeps the settings for the next time kenten are switched on. + body = serde_json::Map::new(); + } + body.insert("kenten".into(), json!(on)); + format_chars(s, &Value::Object(body)) +} + +pub(super) fn shatai_cmd(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "type.shatai"; + let mut body = serde_json::Map::new(); + if let Some(v) = finite_param(p, "magnification", CMD)? { + body.insert("shataiMagnification".into(), json!(v.clamp(0.0, 90.0))); + } + if let Some(v) = finite_param(p, "angle", CMD)? { + body.insert("shataiAngle".into(), json!(v.rem_euclid(180.0))); + } + if let Some(v) = bool_param(p, "adjustRotation", CMD)? { + body.insert("shataiAdjustRotation".into(), json!(v)); + } + if let Some(v) = bool_param(p, "adjustTsume", CMD)? { + body.insert("shataiAdjustTsume".into(), json!(v)); + } + if body.is_empty() { + return Err(bad(CMD, "nothing to set: magnification, angle, adjustRotation or adjustTsume")); + } + format_chars(s, &Value::Object(body)) +} + +pub(super) fn auto_tcy_cmd(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "type.autoTateChuYoko"; + let mut body = serde_json::Map::new(); + if let Some(v) = finite_param(p, "digits", CMD)? { + let max = f64::from(designcraft_compose::shape::AUTO_TCY_MAX); + body.insert("autoTcy".into(), json!(v.round().clamp(0.0, max) as u32)); + } + if let Some(v) = bool_param(p, "includeRoman", CMD)? { + body.insert("autoTcyIncludeRoman".into(), json!(v)); + } + if body.is_empty() { + return Err(bad(CMD, "nothing to set: digits or includeRoman")); + } + format_paras(s, &Value::Object(body)) +} + +pub(super) fn grid_settings_cmd(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::cjk::CharacterAlignment as R; + const CMD: &str = "type.gridSettings"; + let mut body = serde_json::Map::new(); + let first_only = bool_param(p, "firstLineOnly", CMD)?; + match text_param(p, "alignment", CMD, 32)?.as_deref() { + None => { + if let Some(f) = first_only { + body.insert("gridAlign".into(), json!(if f { "firstLineOnly" } else { "allLines" })); + } + } + Some("none") => { + body.insert("gridAlign".into(), json!("none")); + } + Some(name) => { + let reference = match name { + "romanBaseline" | "baseline" => R::Baseline, + "emTop" => R::EmTop, + "emCenter" => R::EmCenter, + "emBottom" => R::EmBottom, + "icfTop" => R::IcfTop, + "icfBottom" => R::IcfBottom, + other => return Err(bad(CMD, format!("unknown alignment `{other}`"))), + }; + body.insert("gridAlign".into(), json!(if first_only == Some(true) { "firstLineOnly" } else { "allLines" })); + body.insert("gridReference".into(), json!(reference)); + } + } + if let Some(v) = finite_param(p, "gyoudori", CMD)? { + let max = 100.0; + body.insert("gridGyoudori".into(), json!(v.round().clamp(0.0, max) as u32)); + } + if let Some(v) = bool_param(p, "paragraphGyoudori", CMD)? { + body.insert("paragraphGyoudori".into(), json!(v)); + } + if body.is_empty() { + return Err(bad(CMD, "nothing to set: alignment, firstLineOnly, gyoudori or paragraphGyoudori")); + } + format_paras(s, &Value::Object(body)) +} + +fn ruby_text<'a>(p: &'a Value, key: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + let t = v.as_str().ok_or_else(|| bad("type.ruby", format!("{key}: expected a string")))?; + if t.chars().count() > designcraft_doc::ruby::MAX_RUBY_CHARS { + return Err(bad("type.ruby", format!("{key}: longer than {} characters", designcraft_doc::ruby::MAX_RUBY_CHARS))); + } + Ok(Some(t)) +} + +pub(super) fn ruby_cmd(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::cjk_settings::AdornmentOverprint; + use designcraft_doc::ruby::{RubyAlignment, RubyOverhang, RubyParentSpacing, RubyPosition, RubyType}; + let mut body = serde_json::Map::new(); + let mut put = |attr: &str, v: Option| { + if let Some(v) = v { + body.insert(attr.into(), v); + } + }; + put("ruby", ruby_text(p, "text")?.map(|t| json!(t))); + put("rubyType", ruby_enum::(p, "type")?); + put("rubyAlignment", ruby_enum::(p, "alignment")?); + put("rubyPosition", ruby_enum::(p, "position")?); + put("rubyOverhangAmount", ruby_enum::(p, "overhang")?); + put("rubyParentSpacing", ruby_enum::(p, "parentSpacing")?); + put("rubyOverprintFill", ruby_enum::(p, "overprintFill")?); + put("rubyOverprintStroke", ruby_enum::(p, "overprintStroke")?); + put("rubyXOffset", ruby_num(p, "xOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); + put("rubyYOffset", ruby_num(p, "yOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); + put("rubyFontSize", ruby_num(p, "size", 0.1, 1296.0, 1.0)?); + put("rubyXScale", ruby_num(p, "xScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); + put("rubyYScale", ruby_num(p, "yScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); + put("rubyScalingMin", ruby_num(p, "scalingPercent", 10.0, 100.0, 100.0)?.filter(|v| !v.is_null())); + put("rubyFillTint", ruby_num(p, "fillTint", 0.0, 100.0, 100.0)?); + put("rubyStrokeTint", ruby_num(p, "strokeTint", 0.0, 100.0, 100.0)?); + put("rubyStrokeWeight", ruby_num(p, "strokeWeight", 0.0, 800.0, 1.0)?); + put( + "rubyAutoTcyDigits", + ruby_num(p, "autoTcyDigits", 0.0, 9.0, 1.0)?.filter(|v| !v.is_null()).and_then(|v| v.as_f64()).map(|v| json!(v.round() as u32)), + ); + for (key, attr) in [ + ("openTypePro", "rubyOpenTypePro"), + ("autoTcyIncludeRoman", "rubyAutoTcyIncludeRoman"), + ("autoTcyAutoScale", "rubyAutoTcyAutoScale"), + ("autoAlign", "rubyAutoAlign"), + ("autoScaling", "rubyAutoScaling"), + ] { + if let Some(v) = p.get(key) { + let b = v.as_bool().ok_or_else(|| bad("type.ruby", format!("{key}: expected true or false")))?; + put(attr, Some(json!(b))); + } + } + put("rubyFont", ruby_text(p, "font")?.map(|t| json!(t))); + put("rubyFontStyle", ruby_text(p, "fontStyle")?.map(|t| json!(t))); + for (key, attr) in [("fill", "rubyFill"), ("stroke", "rubyStroke")] { + if let Some(name) = ruby_text(p, key)? { + let known = name.is_empty() || name == designcraft_doc::color::swatch::NONE || s.doc()?.doc.swatch(name).is_some(); + if !known { + return Err(bad("type.ruby", format!("{key}: no swatch `{name}`"))); + } + put(attr, Some(json!(name))); + } + } + format_chars(s, &Value::Object(body)) +} + +#[cfg(test)] +mod tcy_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tate_chu_yoko_toggles_on_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "令和12年", "vertical": true})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let at = "令和".len(); + s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at + 2})).unwrap(); + let get = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(at + 1).over.tate_chu_yoko; + s.execute("type.tateChuYoko", &json!({})).unwrap(); + assert_eq!(get(&s), Some(true)); + s.execute("type.tateChuYoko", &json!({})).unwrap(); + assert_eq!(get(&s), Some(false)); + s.execute("type.tateChuYoko", &json!({"on": true})).unwrap(); + assert_eq!(get(&s), Some(true)); + } + + #[test] + fn ruby_and_kenten_on_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); + s.execute("type.ruby", &json!({"text": "かんじ"})).unwrap(); + s.execute("type.kenten", &json!({})).unwrap(); + let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); + assert_eq!(f(&s).ruby.as_deref(), Some("かんじ")); + assert_eq!(f(&s).kenten, Some(true)); + s.execute("type.kenten", &json!({})).unwrap(); + s.execute("type.ruby", &json!({"text": ""})).unwrap(); + assert_eq!(f(&s).kenten, Some(false)); + assert_eq!(f(&s).ruby.as_deref(), Some("")); + } + + #[test] + fn ruby_options_are_checked_and_clamped() { + use designcraft_doc::ruby::{RubyAlignment, RubyPosition, RubyType}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); + let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); + s.execute( + "type.ruby", + &json!({"text": "かん\u{3000}じ", "type": "perCharacter", "alignment": "oneAki", "position": "belowLeft", "size": 1e9, "xScale": -50, "scalingPercent": 5, "autoTcyDigits": 99, "fillTint": 250, "yOffset": -1e12, "fill": "[Black]"}), + ) + .unwrap(); + let a = f(&s); + assert_eq!( + (a.ruby.as_deref(), a.ruby_type, a.ruby_position), + (Some("かん\u{3000}じ"), Some(RubyType::PerCharacter), Some(RubyPosition::BelowLeft)) + ); + assert_eq!(a.ruby_font_size, Some(Some(1296.0))); + assert_eq!(a.ruby_x_scale, Some(0.01)); + assert_eq!(a.ruby_scaling_min, Some(0.1)); + assert_eq!(a.ruby_auto_tcy_digits, Some(9)); + assert_eq!(a.ruby_fill_tint, Some(Some(1.0))); + assert_eq!(a.ruby_y_offset, Some(-1000.0)); + // Options alone keep the reading. + s.execute("type.ruby", &json!({"alignment": "center"})).unwrap(); + assert_eq!((f(&s).ruby, f(&s).ruby_alignment), (a.ruby.clone(), Some(RubyAlignment::Center))); + // Refused, and nothing changes: unknown names, non-numbers, overlong text, unknown swatches. + let long = "あ".repeat(designcraft_doc::ruby::MAX_RUBY_CHARS + 1); + for p in [ + json!({"alignment": "sideways"}), + json!({"size": "big"}), + json!({"text": long}), + json!({"fill": "Nope"}), + json!({"autoAlign": 1}), + json!({"text": 5}), + ] { + assert!(s.execute("type.ruby", &p).is_err(), "{p}"); + } + assert_eq!(f(&s).ruby_alignment, Some(RubyAlignment::Center)); + assert_eq!(f(&s).ruby, a.ruby); + } + + #[test] + fn warichu_toggles_on_the_selection_and_undoes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let n = "注釈".len(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap(); + s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2})) + .unwrap(); + let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); + let a = f(&s); + assert_eq!(a.warichu, Some(true)); + assert_eq!(a.warichu_lines, Some(2)); + assert_eq!(a.warichu_size, Some(40.0)); + assert_eq!(a.warichu_line_spacing, Some(1.0)); + assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left)); + assert_eq!(a.warichu_chars_before_break, Some(2)); + assert_eq!(a.warichu_chars_after_break, Some(2)); + // The characters after the selection are not part of the note. + assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, None); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, Some(true)); + s.execute("type.warichu", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, Some(false)); + } + + #[test] + fn warichu_up_and_down_move_between_the_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "ABCDEFGHZ"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap(); + s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert!((4..8).contains(&down), "down enters the second row: {down}"); + let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert!(up < 4, "up returns to the first row: {up}"); + s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap(); + let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row"); + } +} diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs new file mode 100644 index 0000000000..ebdda01892 --- /dev/null +++ b/crates/engine/src/cmd/text/caret.rs @@ -0,0 +1,761 @@ +use super::*; + +pub(crate) fn story_of(s: &Session, p: &Value) -> Option { + let st = s.active()?; + if let Some(v) = p.get("story").and_then(Value::as_u64) { + return Some(StoryId(v)); + } + let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()); + if let Some(f) = frame { + return st.doc.item(f).and_then(|i| i.text_frame()).map(|t| t.story); + } + st.selection.text.map(|t| t.story) +} + +/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell). +fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option)> { + hit_byte_with(s, frame, pt, compose::hit) +} + +/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`] +/// (the character under the point). +pub(super) fn hit_byte_with( + s: &Session, + frame: ItemId, + pt: Point, + hit: fn(&compose::ComposedStory, usize, Point) -> Option, +) -> Option<(StoryId, usize, Option)> { + let st = s.active()?; + let loc = st.doc.find(frame)?; + let it = st.doc.item_at(&loc)?; + let sid = it.text_frame()?.story; + let xf = st.doc.parent_xf(&loc) * st.doc.text_xf(it); + let inner = xf.inverse() * pt; + let cs = s.cache.get(&st.doc, sid, None); + let fi = cs.frames.iter().position(|f| f.frame == frame)?; + // Map path-text hits along the curve before choosing the character unit. + let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); + if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) { + return Some((sid, b, Some(CellAddr { table, row, col }))); + } + if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) { + return Some((sid, b, Some(CellAddr::footnote(id)))); + } + let b = hit(&cs, fi, inner).unwrap_or(0); + Some((sid, b, None)) +} + +/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks). +pub(super) fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { + const ID: &str = "text.selectAt"; + let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; + let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; + let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; + let (a, e) = unit_at(s, sid, cell, b, unit, ID)?; + s.text_drag = false; + s.typing_format = None; + let sel = TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }; + s.text_unit = Some(sel); + let st = s.doc_mut()?; + st.selection = Selection::text(sel); + // Explicit reselection invalidates the Find scope even when the range is unchanged. + st.bump_revision(); + Ok(json!({"story": sid.0, "anchor": a, "focus": e})) +} + +/// The `unit` (word, line, paragraph or story) around byte `b` of story `sid` (a cell's story +/// with `cell`), as the multiple clicks select it. +fn unit_at(s: &Session, sid: StoryId, cell: Option, b: usize, unit: &str, cmd: &str) -> Result<(usize, usize)> { + let st = s.doc()?; + let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let text = story.text.as_str(); + let b = floor_char_boundary(text, b); + Ok(match unit { + "word" => word_or_spaces_at(text, b), + "line" => { + let cs = s.cache.get(&st.doc, sid, None); + line_at(&text_composition(cs, cell), b).unwrap_or((b, b)) + } + // The paragraph with its return (the last paragraph has none). + "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))), + "story" => (0, text.len()), + other => return Err(bad(cmd, format!("unknown unit `{other}` (word, line, paragraph or story)"))), + }) +} + +/// The word at byte `i`; on spaces the run of spaces; on anything else that one character +/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split: +/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base. +fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { + use unicode_segmentation::UnicodeSegmentation; + let i = floor_char_boundary(s, i.min(s.len())); + // Clusters never span a paragraph return, so the paragraph is enough. + let ps = s[..i].rfind('\n').map_or(0, |k| k + 1); + let pe = s[i..].find('\n').map_or(s.len(), |k| i + k); + let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect(); + let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) }; + let kind = |c: &str| match c.chars().next() { + Some(c) if is_word(c) => 1, + Some(c) if c.is_whitespace() => 2, + _ => 0, + }; + let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len())); + let kd = g.get(k).map_or(0, |(_, c)| kind(c)); + if kd == 0 { + return span(k, k); + } + let mut a = k; + while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) { + a -= 1; + } + let mut e = k; + while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) { + e += 1; + } + span(a, e) +} + +/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's. +fn text_composition(cs: std::sync::Arc, cell: Option) -> std::sync::Arc { + match cell { + Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs), + Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs), + None => cs, + } +} + +/// The composed line holding the character at byte `pos`. +fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { + let lines = || cs.frames.iter().flat_map(|f| f.lines.iter()); + lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos)) +} + +pub(super) fn place(s: &mut Session, p: &Value, extend: bool) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + // Empty frames become text frames; a locked one stays as it is. + let st = s.doc()?; + let locked = st.doc.item(frame).is_some_and(|i| i.locked || st.doc.layer(i.layer).is_some_and(|l| l.locked)); + let needs_convert = st.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); + if needs_convert && locked { + return Err(bad("text.placeCaret", "the frame is locked")); + } + if needs_convert { + s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?; + } + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?; + // A drag after several clicks: whole units from the one first selected, in either direction. + if extend + && let Some(unit) = str_param(p, "unit") + && let Some(u) = s.text_unit.filter(|u| u.story == sid && u.cell == cell) + { + // The remembered unit may predate edits that shortened the text. + let text = s.doc()?.doc.text_story(sid, cell).map_or("", |t| t.text.as_str()); + let (ua, ue) = (floor_char_boundary(text, u.anchor.min(u.focus)), floor_char_boundary(text, u.anchor.max(u.focus))); + let (na, ne) = unit_at(s, sid, cell, b, unit, "text.extendTo")?; + let (anchor, focus) = if na < ua { (ue, na) } else { (ua, ne.max(ue)) }; + s.typing_format = None; + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.bump_revision(); + return Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})); + } + // Drag and drop: a press inside the selected text keeps it (to be dragged). + if !extend { + s.text_drag = s.doc()?.selection.text.is_some_and(|t| t.story == sid && t.cell == cell && !t.range().is_empty() && t.range().contains(&b)); + if s.text_drag { + return Ok(json!({"story": sid.0, "dragging": true})); + } + } else if s.text_drag { + return Ok(json!({"story": sid.0, "dragging": true})); + } + s.typing_format = None; + let st = s.doc_mut()?; + // Dragging from one cell into another selects cells. + if extend + && let (Some(t), Some(c)) = (st.selection.text, cell) + && let Some(a) = t.cell + && t.story == sid + && a.table == c.table + && (a.row, a.col) != (c.row, c.col) + { + let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col); + st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range }); + st.bump_revision(); + return Ok(json!({"story": sid.0, "cells": range})); + } + let t = match (extend, st.selection.text) { + (true, Some(t)) if t.story == sid && t.cell == cell => TextSel { focus: b, frame: Some(frame), ..t }, + _ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell }, + }; + st.selection = Selection::text(t); + st.bump_revision(); + Ok(json!({"story": sid.0, "pos": b, "cell": cell})) +} + +pub(super) fn release(s: &mut Session, p: &Value) -> Result { + if !std::mem::take(&mut s.text_drag) { + return ok(); + } + let frame = id_param(p, "frame").ok_or_else(|| bad("text.release", "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + if !p.get("moved").and_then(Value::as_bool).unwrap_or(false) { + // A click in the selection: the caret goes there. + s.doc_mut()?.selection.text = None; + return place(s, &json!({"frame": frame.0, "point": [pt.x, pt.y]}), false); + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let Some(t) = s.doc()?.selection.text else { return ok() }; + let Some((sid, b, cell)) = hit_byte(s, frame, pt) else { return ok() }; + let r = t.range(); + if sid != t.story || cell != t.cell || (r.contains(&b) && !copy) { + return ok(); + } + s.edit(|d, sel| { + let st = d.text_story_mut(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let piece = st.extract(r.clone()); + let mut at = b; + if !copy { + st.delete(r.clone()); + if at > r.start { + at -= r.len().min(at - r.start); + } + } + let end = st.insert_story(at, &piece); + sel.text = Some(TextSel { story: sid, anchor: at, focus: end, frame: Some(frame), cell }); + Ok(json!({"moved": piece.text.len(), "at": at})) + }) +} + +pub fn next_char(s: &str, i: usize) -> usize { + s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) +} + +pub fn prev_char(s: &str, i: usize) -> usize { + s[..i.min(s.len())].chars().next_back().map(|c| i - c.len_utf8()).unwrap_or(0) +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '\'' || c == '’' +} + +pub fn next_word(s: &str, i: usize) -> usize { + let mut j = i; + let mut seen = false; + for (k, c) in s[i..].char_indices() { + if is_word(c) { + seen = true; + } else if seen { + return i + k; + } + j = i + k + c.len_utf8(); + } + j +} + +pub fn prev_word(s: &str, i: usize) -> usize { + let mut seen = false; + for (k, c) in s[..i].char_indices().rev() { + if is_word(c) { + seen = true; + } else if seen { + return k + c.len_utf8(); + } + } + 0 +} + +/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret +/// placement, a press inside the current selection is no start of a text drag here: it is the +/// third or later click of a row, which lands in the word or sentence just selected. +pub(super) fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; + let st = s.doc_mut()?; + let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); + let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); + s.text_drag = false; + s.typing_format = None; + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.bump_revision(); + Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) +} + +/// The paragraph around byte `i`, without its `\n`. +pub(super) fn para_bounds(s: &str, i: usize) -> (usize, usize) { + let i = i.min(s.len()); + let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); + let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); + (a, b) +} + +/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and +/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full +/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. +pub(super) fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { + let (pa, pb) = para_bounds(s, i); + let para = s.get(pa..pb).unwrap_or(""); + let mut start = 0; + let mut it = para.char_indices().peekable(); + while let Some((_, c)) = it.next() { + let cjk = matches!(c, '。' | '!' | '?'); + if !cjk && !matches!(c, '.' | '!' | '?' | '…') { + continue; + } + while it.peek().is_some_and(|&(_, c)| { + matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') + }) { + it.next(); + } + if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { + continue; + } + while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { + it.next(); + } + let end = it.peek().map_or(para.len(), |&(k, _)| k); + if pa + end > i || end == para.len() { + return (pa + start, pa + end); + } + start = end; + } + (pa + start, pb) +} + +pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result { + let dir = str_param(p, "dir").unwrap_or("right").to_string(); + let extend = bool_or(p, "extend", false); + let word = bool_or(p, "word", false); + let st = s.doc()?; + let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?; + let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let text = story.text.clone(); + // In a cell, lines come from the cell's own composition. + let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell); + let pos = t.focus.min(text.len()); + // Vertical lines run top to bottom and follow each other right to left: Down and Up move along + // the line (Right and Left of horizontal text), Left and Right to the next and previous line. + let vertical_text = compose::caret(&cs, pos).and_then(|(fi, ..)| cs.frames.get(fi)).is_some_and(|f| f.vertical); + let dir = match (vertical_text, dir.as_str()) { + (true, "down") => "right", + (true, "up") => "left", + (true, "left") => "down", + (true, "right") => "up", + (_, d) => d, + }; + let collapse_to = |left: bool| if left { t.range().start } else { t.range().end }; + let new = match dir { + "left" if !extend && !t.is_caret() => collapse_to(true), + "right" if !extend && !t.is_caret() => collapse_to(false), + "left" | "right" => { + // Lines with right-to-left text move as drawn; at a line's edge, or by words, a + // right-to-left line's "left" is forward in the text. + let left = dir == "left"; + let visual = if word { None } else { compose::visual_step(&cs, pos, left) }; + match visual { + Some(p) => p, + None => { + let forward = left == compose::line_rtl(&cs, pos); + match (forward, word) { + (true, true) => next_word(&text, pos), + (true, false) => next_char(&text, pos), + (false, true) => prev_word(&text, pos), + (false, false) => prev_char(&text, pos), + } + } + } + } + "up" | "down" => vertical(&cs, pos, dir == "up").unwrap_or(pos), + "lineStart" => line_of(&cs, pos).map(|l| l.0).unwrap_or(0), + "lineEnd" => line_of(&cs, pos).map(|l| l.1).unwrap_or(text.len()), + "storyStart" => 0, + "storyEnd" => text.len(), + other => return Err(bad("text.move", format!("unknown dir `{other}`"))), + }; + s.typing_format = None; + let stm = s.doc_mut()?; + stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); + stm.bump_revision(); + Ok(json!({"pos": new})) +} + +fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { + cs.frames.iter().flat_map(|f| f.lines.iter()).find(|l| pos >= l.range.start && pos <= l.range.end).map(|l| (l.range.start, l.range.end)) +} + +fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option { + let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?; + let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect(); + // Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it + // does not equal the parent line's baseline. + let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?; + let (_, l) = lines.get(cur)?; + if let Some(y) = compose::adjacent_row(l, x, caret_y, up) + && let Some(p) = compose::hit(cs, fi, Point::new(x, y)) + && p != pos + { + return Some(p); + } + // The next row: segments of the same line (beside a wrap object) share its baseline. + let other_row = |(i, t): &&(usize, &compose::Line)| *i != fi || (t.baseline - l.baseline).abs() > 1e-6; + let (tf, tl) = if up { lines.get(..cur)?.iter().rfind(other_row)? } else { lines.get(cur + 1..)?.iter().find(other_row)? }; + compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0)) +} + +#[cfg(test)] +mod multi_click_tests { + use serde_json::{Value, json}; + + use crate::Session; + + #[test] + fn moving_the_caret_drops_the_pending_typing_format() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + let pending = |s: &mut Session| { + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + }; + // Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the + // same caret is in the text's own format. + let moves: Vec> = vec![ + Box::new(|s| { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + }), + Box::new(|s| { + s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + Box::new(|s| { + s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + ]; + for m in &moves { + pending(&mut s); + m(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), None); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Without a move it applies. + pending(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), Some(30.0)); + // Selecting a sentence or paragraph around a point drops it too. + for id in ["text.selectSentence", "text.selectParagraph"] { + pending(&mut s); + assert!(s.typing_format.is_some()); + s.execute(id, &json!({"frame": fid, "point": [80, 80]})).unwrap(); + assert!(s.typing_format.is_none(), "{id}"); + } + } + + #[test] + fn word_selection_keeps_grapheme_clusters_whole() { + use super::word_or_spaces_at as word; + // Decomposed "café": the combining acute belongs to the word, wherever the click lands. + let s = "un cafe\u{301} noir"; + let cafe = (3, 3 + "cafe\u{301}".len()); + assert_eq!(word(s, 4), cafe); + assert_eq!(word(s, 7), cafe, "on the combining mark"); + // Devanagari with a virama (U+094D) and a dependent vowel sign. + let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x"; + let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len(); + assert_eq!(word(s, 0), (0, w)); + assert_eq!(word(s, 9), (0, w), "on the virama"); + // An emoji ZWJ sequence and a variation selector stay one character. + let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}"; + let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len()); + assert_eq!(word(s, 2), e); + assert_eq!(word(s, 6), e, "on the joiner"); + assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole"); + // Empty paragraphs select nothing. + assert_eq!(word("a\n\nb", 2), (2, 2)); + } + + /// Issue #981: holding the button after a double, triple or quadruple click and moving extends + /// the selection a word, line or paragraph at a time, from the unit first selected, either way. + #[test] + fn a_drag_after_several_clicks_extends_the_selection_by_units() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Hello brave world.\nSecond paragraph here."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); + json!([g.x + g.adv * 0.75, l.baseline - 3.0]) + }; + let sel = |s: &Session| s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)).unwrap(); + let extend = |s: &mut Session, b: usize, unit: &str| { + let p = at(s, b); + s.execute("text.extendTo", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); + sel(s) + }; + // Double-click "brave", drag into "world.": whole words, anchored at the start of "brave". + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); + assert_eq!(sel(&s), (7, 12)); + assert_eq!(extend(&mut s, 14, "word"), (7, 18), "forward: through \"world\""); + assert_eq!(extend(&mut s, 9, "word"), (7, 12), "back inside the word: just the word"); + assert_eq!(extend(&mut s, 1, "word"), (12, 0), "backward: from \"Hello\" to the end of \"brave\""); + assert_eq!(&text[0..12], "Hello brave"); + // Triple-click the first line, drag into the second: both lines. + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "line"})).unwrap(); + assert_eq!(sel(&s), (0, 19)); + assert_eq!(extend(&mut s, 22, "line"), (0, text.len())); + // Without `unit` a drag still moves the focus a character at a time. + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); + s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14)})).unwrap(); + assert_eq!(sel(&s).0, 7); + assert!(sel(&s).1 > 12 && sel(&s).1 < 18, "{:?}", sel(&s)); + assert!(s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14), "unit": "sentence"})).is_err()); + // A unit remembered before the text was deleted stays inside what is left. + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 14), "unit": "word"})).unwrap(); + s.execute("story.setText", &json!({"story": sid, "text": ""})).unwrap(); + s.execute("text.extendTo", &json!({"frame": fid, "point": [100, 90], "unit": "word"})).unwrap(); + assert_eq!(sel(&s), (0, 0)); + } + + #[test] + fn clicks_select_words_spaces_lines_paragraphs_and_the_story() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Hello brave world.\nSecond paragraph here."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + // Spread point over the right half of the glyph at byte `b`. + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); + json!([g.x + g.adv * 0.75, l.baseline - 3.0]) + }; + let mut select = |unit: &str, b: usize| { + let p = at(&s, b); + let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); + (r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize) + }; + assert_eq!(select("word", 1), (0, 5), "inside a word"); + assert_eq!(select("word", 4), (0, 5), "its last letter"); + assert_eq!(select("word", 5), (5, 7), "the run of spaces after it"); + assert_eq!(select("word", 18), (18, 19), "punctuation alone"); + assert_eq!(select("line", 9), (0, 19)); + assert_eq!(select("paragraph", 9), (0, 20), "with its return"); + assert_eq!(select("paragraph", 22), (20, text.len())); + assert_eq!(select("story", 9), (0, text.len())); + // A second click inside the selected word selects again rather than starting a text drag. + s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap(); + let p = at(&s, 9); + s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19)); + s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it"); + // The older word command agrees. + let p = at(&s, 8); + s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (7, 12)); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err()); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point"); + } +} + +#[cfg(test)] +mod drag_text_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn drag_selected_text_to_move_or_copy_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello brave world"})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + // Spread point of the glyph at byte `b`. + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let l = &cs.frames[0].lines[0]; + let g = l.glyphs.iter().find(|g| g.byte == b).unwrap(); + json!([g.x + g.adv * 0.25, l.baseline - 3.0]) + }; + let end = |s: &Session| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let l = &cs.frames[0].lines[0]; + json!([l.end_x + 2.0, l.baseline - 3.0]) + }; + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 12})).unwrap(); + let r = s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 8)})).unwrap(); + assert_eq!(r["dragging"], true); + s.execute("text.extendTo", &json!({"frame": fid, "point": end(&s)})).unwrap(); + s.execute("text.release", &json!({"frame": fid, "point": end(&s), "moved": true})).unwrap(); + let text = |s: &Session| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); + assert_eq!(text(&s), "Hello worldbrave "); + // Alt-drag copies (the moved text is still selected). + s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 12)})).unwrap(); + s.execute("text.release", &json!({"frame": fid, "point": at(&s, 0), "moved": true, "copy": true})).unwrap(); + assert_eq!(text(&s), "brave Hello worldbrave "); + // A click inside the selection just places the caret. + s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 2)})).unwrap(); + s.execute("text.release", &json!({"frame": fid, "point": at(&s, 2), "moved": false})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!(t.anchor, t.focus); + } +} + +#[cfg(test)] +mod bidi_caret_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn arrow_keys_in_a_right_to_left_paragraph() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "سلام عليكم"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("type.para", &json!({"attrs": {"direction": "rightToLeft"}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + // Left moves forward through right-to-left text, Right back. + let p = s.execute("text.move", &json!({"dir": "left"})).unwrap()["pos"].as_u64().unwrap(); + assert_eq!(p, 'س'.len_utf8() as u64); + let p = s.execute("text.move", &json!({"dir": "right"})).unwrap()["pos"].as_u64().unwrap(); + assert_eq!(p, 0); + // By words too. + let p = s.execute("text.move", &json!({"dir": "left", "word": true})).unwrap()["pos"].as_u64().unwrap(); + assert!(p >= "سلام".len() as u64, "{p}"); + } +} + +#[cfg(test)] +mod vertical_caret_tests { + use designcraft_doc::StoryId; + use serde_json::json; + + use crate::Session; + + /// A story set in three or more lines; returns its id. + fn story(s: &mut Session, vertical: bool) -> StoryId { + s.execute("file.new", &json!({})).unwrap(); + let text = "一二三四五六七八九十".repeat(8); + let rect = if vertical { [72, 72, 200, 400] } else { [72, 72, 400, 200] }; + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "vertical": vertical})).unwrap(); + StoryId(r["story"].as_u64().unwrap()) + } + + fn mv(s: &mut Session, dir: &str, extend: bool, word: bool) -> usize { + s.execute("text.move", &json!({"dir": dir, "extend": extend, "word": word})).unwrap()["pos"].as_u64().unwrap() as usize + } + + /// (line index, position along the line) of the caret at `pos`. + fn place(s: &Session, sid: StoryId, pos: usize) -> (usize, f64) { + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let (_, x, baseline, _, _) = crate::compose::caret(&cs, pos).unwrap(); + let line = cs.frames[0].lines.iter().position(|l| (l.baseline - baseline).abs() < 0.01).unwrap(); + (line, x) + } + + #[test] + fn arrow_keys_follow_vertical_lines() { + let mut s = Session::new(); + let sid = story(&mut s, true); + assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines.len() >= 3); + let c = '一'.len_utf8(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); + // Down and Up move along the line, a character at a time. + assert_eq!(mv(&mut s, "down", false, false), 4 * c); + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + // Left moves to the next line (lines follow each other right to left), keeping the + // position along the line; Right comes back. + let (line, x) = place(&s, sid, 3 * c); + let next = mv(&mut s, "left", false, false); + let (nline, nx) = place(&s, sid, next); + assert_eq!(nline, line + 1); + assert!((nx - x).abs() < 0.5, "{x} {nx}"); + assert_eq!(mv(&mut s, "right", false, false), 3 * c); + // Shift extends. + assert_eq!(mv(&mut s, "down", true, false), 4 * c); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (3 * c, 4 * c)); + let p = mv(&mut s, "left", true, false); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (3 * c, p)); + assert_eq!(place(&s, sid, p).0, line + 1); + // With a selection, Up collapses it to its start. + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + // By words: Alt+Down is the vertical Alt+Right. + let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone(); + assert_eq!(mv(&mut s, "down", false, true), super::next_word(&text, 3 * c)); + } + + #[test] + fn arrow_keys_in_horizontal_lines() { + let mut s = Session::new(); + let sid = story(&mut s, false); + let c = '一'.len_utf8(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); + assert_eq!(mv(&mut s, "right", false, false), 4 * c); + assert_eq!(mv(&mut s, "left", false, false), 3 * c); + let (line, x) = place(&s, sid, 3 * c); + let next = mv(&mut s, "down", false, false); + let (nline, nx) = place(&s, sid, next); + assert_eq!(nline, line + 1); + assert!((nx - x).abs() < 0.5, "{x} {nx}"); + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + } +} + +#[cfg(test)] +mod sentence_selection_tests { + use super::{para_bounds, sentence_bounds}; + + fn sentence(s: &str, at: &str) -> String { + let i = s.find(at).unwrap(); + let (a, b) = sentence_bounds(s, i); + s[a..b].to_string() + } + + #[test] + fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { + let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; + assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); + assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); + assert_eq!(sentence(s, "Twice"), "(Twice!) "); + assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); + assert_eq!(sentence(s, "fine"), "fine."); + assert_eq!(sentence(s, "one"), "Next one. "); + assert_eq!(sentence(s, "Last"), "Last"); + // The caret at the very end of a paragraph is in its last sentence. + let end = s.find('\n').unwrap(); + assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); + } + + #[test] + fn cjk_full_stops_end_sentences_without_a_space() { + let s = "これは文です。次の文!最後"; + assert_eq!(sentence(s, "次"), "次の文!"); + assert_eq!(sentence(s, "最後"), "最後"); + } + + #[test] + fn paragraphs_stop_at_returns() { + let s = "One.\nTwo two.\n"; + assert_eq!(para_bounds(s, 6), (5, 13)); + assert_eq!(para_bounds(s, 0), (0, 4)); + assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); + } +} diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs new file mode 100644 index 0000000000..627255d316 --- /dev/null +++ b/crates/engine/src/cmd/text/edit.rs @@ -0,0 +1,728 @@ +use super::caret::{next_char, next_word, prev_char, prev_word, story_of}; +use super::format::{apply_typing_format, format_targets, typing_format}; +use super::*; + +// Each command edits a copy-on-write Story snapshot. Reserving its known final text growth +// avoids retaining geometric String spare capacity in every independent undo snapshot. +// General Story insertion keeps its existing amortized-growth policy for mutable builders. +fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> Result<()> { + st.text.try_reserve_exact(additional).map_err(|e| crate::EngineError::Other(format!("could not reserve text insertion: {e}"))) +} + +pub(super) fn insert(s: &mut Session, p: &Value) -> Result { + let text = crate::cmd::text_param(p, "text"); + // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). + if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { + return crate::cmd::table::step_cell(s, true); + } + let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); + let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; + let tracking = s.doc()?.doc.settings.track_changes; + let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone()); + let typing = typing_format(s).cloned(); + let result = s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?; + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let r = t.range(); + let mut r = r.start.min(st.len())..r.end.min(st.len()); + // Between a break character and its return is the end of that paragraph: typing there goes + // to the start of the next one. + if r.is_empty() + && st.text.get(r.start..).is_some_and(|t| t.starts_with('\n')) + && st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char) + { + r = r.start + 1..r.start + 1; + } + // Autocorrect: a word ended by a space or punctuation is looked up. + if let Some(list) = &autocorrect + && r.is_empty() + && text.chars().count() == 1 + && text.chars().all(|c| c.is_whitespace() || c.is_ascii_punctuation()) + { + let before = &st.text[..r.start]; + let ws = before.char_indices().rev().take_while(|(_, c)| c.is_alphabetic()).last().map(|(i, _)| i); + if let Some(ws) = ws { + let word = &before[ws..]; + if let Some((_, to)) = list.iter().find(|(from, _)| from.eq_ignore_ascii_case(word)) { + let to = if word.chars().next().is_some_and(char::is_uppercase) { + let mut c = to.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } else { + to.clone() + }; + st.replace(ws..r.start, &to); + r = ws + to.len()..ws + to.len(); + } + } + } + // A column, frame or page break ends its paragraph. + let mut text = if text.contains(designcraft_doc::is_break_char) { + designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next())) + } else { + text.clone() + }; + // Typing next to a table anchor starts a paragraph of its own. + let mut trail = 0; + if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() { + let pi = st.para_at(r.start); + let pr = st.para_ranges()[pi].clone(); + let anchor_at = pr.start + st.text[pr.clone()].find(designcraft_doc::TABLE_ANCHOR).unwrap_or(0); + if r.start <= anchor_at { + if !text.ends_with('\n') { + text.push('\n'); + trail = 1; + } + } else if !text.starts_with('\n') { + text.insert(0, '\n'); + } + } + if tracking && t.cell.is_none() { + // Track Changes: the replaced text is marked deleted, the typing inserted after it. + let at = crate::cmd::changes::mark_deleted(st, r.clone()); + reserve_command_text(st, text.len())?; + let mut fmt = st.insert_format_at(at).clone(); + if let Some(f) = &typing { + apply_typing_format(&mut fmt, f); + } + fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted); + st.insert_with(at, &text, fmt); + let pos = at + text.len() - trail; + sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + return Ok(json!({"pos": pos})); + } + let a = floor_char_boundary(&st.text, r.start.min(st.len())); + let b = floor_char_boundary(&st.text, r.end.min(st.len())).max(a); + reserve_command_text(st, text.len().saturating_sub(b - a))?; + st.replace(r.clone(), &text); + if let Some(f) = &typing + && !text.is_empty() + { + st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f)); + } + let pos = r.start + text.len() - trail; + sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + Ok(json!({"pos": pos})) + }); + // A preview (IME marked text) leaves the typing format to the text typed in the end. + if result.is_ok() && s.active().is_none_or(|d| d.interaction.is_none()) { + s.typing_format = None; + } + result +} + +/// The input method's delete-surrounding: remove `before`/`after` characters (or bytes) around +/// the selection, keeping the selection itself and its direction, then optionally type `commit` +/// over it. One undo step for the whole batch, through the normal command pipeline (preview +/// restoration, panic guard, undo, auto-size and the journal), unlike a private edit. +pub(super) fn delete_surrounding(s: &mut Session, p: &Value) -> Result { + let count = |key: &str| -> Result { + match p.get(key).and_then(Value::as_u64) { + Some(v) => usize::try_from(v).map_err(|_| bad("text.deleteSurrounding", format!("{key} is out of range"))), + None => Ok(0), + } + }; + let before = count("before")?; + let after = count("after")?; + let bytes = p.get("bytes").and_then(Value::as_bool).unwrap_or(false); + let commit = designcraft_tools::ime_commit_text(p.get("commit").and_then(Value::as_str).unwrap_or("")); + // What the request removes, from the committed text as it is now: the ranges exclude the + // selection and any pre-edit (a pre-edit is only a preview, never in the story). + let (before_range, after_range, tracking, length_before) = { + let st = s.doc()?; + let t = st.selection.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; + let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let (before_range, after_range) = crate::ime_delete_ranges(&story.text, t.anchor, t.focus, before, after, bytes) + .ok_or_else(|| bad("text.deleteSurrounding", "the IME text selection is not on character boundaries"))?; + (before_range, after_range, st.doc.settings.track_changes && t.cell.is_none(), story.text.len()) + }; + s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; + let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + if !after_range.is_empty() { + if tracking { + crate::cmd::changes::mark_deleted(story, after_range.clone()); + } else { + story.delete(after_range.clone()); + } + } + let mut shift = 0usize; + if !before_range.is_empty() { + if tracking { + // Tracked changes keep the text and mark it deleted: the selection does not move. + let new_end = crate::cmd::changes::mark_deleted(story, before_range.clone()); + shift = before_range.end.saturating_sub(new_end); + } else { + shift = before_range.len(); + story.delete(before_range.clone()); + } + } + sel.text = Some(TextSel { anchor: t.anchor.saturating_sub(shift), focus: t.focus.saturating_sub(shift), ..t }); + Ok(()) + })?; + if !commit.is_empty() { + insert(s, &json!({"text": commit}))?; + } + let st = s.doc()?; + let (anchor, focus) = st.selection.text.map_or((0, 0), |t| (t.anchor, t.focus)); + let length_after = st.selection.text.and_then(|t| st.doc.text_story(t.story, t.cell)).map_or(0, |story| story.text.len()); + Ok(json!({ + "deletedBefore": [before_range.start, before_range.end], + "deletedAfter": [after_range.start, after_range.end], + "lengthBefore": length_before, + "lengthAfter": length_after, + "anchor": anchor, + "focus": focus, + })) +} + +fn smart_quotes(s: &Session, text: &str) -> String { + if !text.contains(['"', '\'']) { + return text.to_string(); + } + let prev = s + .active() + .and_then(|st| { + let t = st.selection.text?; + let story = st.doc.text_story(t.story, t.cell)?; + story.text[..t.range().start.min(story.len())].chars().last() + }) + .unwrap_or(' '); + // The language at the insertion point picks the quote marks. + let language = s + .active() + .and_then(|st| { + let t = st.selection.text?; + let story = st.doc.text_story(t.story, t.cell)?; + let pos = t.range().start.min(story.len()); + let pi = story.para_at(pos).min(story.paras.len().saturating_sub(1)); + let base = st.doc.styles.resolve_para(story.paras.get(pi)?).1; + Some(st.doc.styles.resolve_char(&base, story.char_format_at(pos)).language) + }) + .unwrap_or_default(); + let [dq_open, dq_close, sq_open, sq_close] = quote_marks(&language); + let mut out = String::with_capacity(text.len()); + let mut last = prev; + for c in text.chars() { + let open = last.is_whitespace() || matches!(last, '(' | '[' | '{' | '\u{2014}' | '\u{2013}'); + let r = match c { + '"' => { + if open { + dq_open + } else { + dq_close + } + } + '\'' => { + if open { + sq_open + } else { + // An apostrophe inside a word stays an apostrophe. + if last.is_alphanumeric() { '\u{2019}' } else { sq_close } + } + } + c => c, + }; + out.push(r); + last = c; + } + out +} + +/// Typographer's quotes of a language (a name or locale code, see +/// [`designcraft_doc::language_tag`]): [double open, double close, single open, single close]. +pub fn quote_marks(language: &str) -> [char; 4] { + const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; + let Some(tag) = designcraft_doc::language_tag(language) else { return ENGLISH }; + // Swiss German, French and Italian use guillemets. + if tag.ends_with("-CH") { + return ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; + } + match tag { + "zh-Hans" => return ENGLISH, + "zh" | "zh-Hant" => return ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], + _ => {} + } + match designcraft_doc::language_subtag(tag) { + "de" | "cs" | "sk" | "bg" | "lt" => ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'], + "fr" | "ru" | "uk" | "no" | "nb" | "nn" | "el" => ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'], + "es" | "it" | "pt" | "ca" => ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'], + "nl" | "pl" | "ro" | "hu" | "hr" => ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'], + "sv" | "fi" => ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'], + "da" => ['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}'], + "ja" => ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], + _ => ENGLISH, + } +} + +pub(crate) fn delete_selection(s: &mut Session) -> Result { + let tracking = s.doc()?.doc.settings.track_changes; + s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; + let r = t.range(); + if let Some(st) = d.text_story_mut(t.story, t.cell) { + if tracking && t.cell.is_none() { + crate::cmd::changes::mark_deleted(st, r.clone()); + } else { + st.delete(r.clone()); + } + } + sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); + ok() + }) +} + +pub(super) fn delete(s: &mut Session, p: &Value) -> Result { + let forward = bool_or(p, "forward", false); + let word = bool_or(p, "word", false); + let tracking = s.doc()?.doc.settings.track_changes; + s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let r = t.range(); + let r = if !r.is_empty() { + r + } else if forward { + let end = if word { next_word(&st.text, r.start) } else { next_char(&st.text, r.start) }; + r.start..end + } else { + let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) }; + start..r.start + }; + let r = with_break_pair(&st.text, r); + if tracking && t.cell.is_none() { + // Marked, not removed: the caret steps over the deleted text. + let at = crate::cmd::changes::mark_deleted(st, r.clone()); + let pos = if forward { at.max(r.end.min(st.len())) } else { r.start.min(at) }; + sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + return ok(); + } + st.delete(r.clone()); + sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); + ok() + }) +} + +/// A break character and the return after it end one paragraph: deleting either deletes both. +fn with_break_pair(text: &str, r: std::ops::Range) -> std::ops::Range { + let Some(del) = text.get(r.clone()) else { return r }; + let mut chars = del.chars(); + match (chars.next(), chars.next()) { + (Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) { + Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end, + _ => r, + }, + (Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1, + _ => r, + } +} + +/// Our own filler text (not Adobe's). +pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ +et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ +Sic erat, ut nostris ceperunt pectora verbis, mitescunt flamma solidos campos venti. Moventur iam nubes, aequora pontus, \ +dum sidera fulgent tacito per inane meatu. Arbore sub magna ludunt pueri, linquunt aurea litora fluctus. \ +Iamque tibi rursus ponet nova carmina vates, et longas umbras quaerit sub tegmine fagi. Omnia mutantur, nihil interit."; + +pub(super) fn fill_placeholder(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()).or_else(|| st.selection.text.and_then(|t| t.frame)); + let sid = match frame.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()) { + Some(t) => t.story, + None => { + if let Some(f) = frame { + s.execute("object.content", &json!({"ids": [f.0], "type": "text"}))?; + } + story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? + } + }; + // Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text + // comes (so a paragraph can be styled on its own). + const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4]; + let mut text = String::new(); + let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); + let doc = s.doc()?.doc.clone(); + let mut d2 = (*doc).clone(); + let (mut paragraph, mut in_paragraph) = (0, 0); + for i in 0..400 { + text.push_str(sentences[i % sentences.len()]); + in_paragraph += 1; + if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] { + text.push('\n'); + (paragraph, in_paragraph) = (paragraph + 1, 0); + } else { + text.push(' '); + } + if i % 3 == 2 { + d2.set_story_text(sid, &text)?; + let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default()); + if cs.is_overset() { + // Trim to the last sentence that fits. + let cut = cs.overset_at.unwrap_or(text.len()).min(text.len()); + let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut); + text.truncate(keep); + break; + } + } + } + s.edit(|d, _| { + d.set_story_text(sid, text.trim_end())?; + ok() + }) +} + +/// The case-changed text of `src`, char by char (a char may map to several, e.g. ß → SS). +pub(crate) fn case_map(src: &str, case: &str) -> Vec { + let mut out = Vec::with_capacity(src.len()); + let mut prev = ' '; + let mut sentence_start = true; + for c in src.chars() { + let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}')); + let up = |c: char| c.to_uppercase().collect::(); + let low = |c: char| c.to_lowercase().collect::(); + // Title and sentence case capitalize only scripts with initial capitals (not Georgian). + let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) }; + let m = match case { + "upper" => up(c), + "lower" => low(c), + "title" => { + if word_start { + cap(c) + } else { + low(c) + } + } + _ => { + if sentence_start && c.is_alphanumeric() { + cap(c) + } else { + low(c) + } + } + }; + if c.is_alphanumeric() { + sentence_start = false; + } else if matches!(c, '.' | '!' | '?' | '\n' | '\u{2028}') { + sentence_start = true; + } + out.push(m); + prev = c; + } + out +} + +pub(super) fn change_case(s: &mut Session, p: &Value) -> Result { + let case = str_param(p, "case").unwrap_or("upper").to_string(); + if !matches!(case.as_str(), "upper" | "lower" | "title" | "sentence") { + return Err(bad("type.changeCase", format!("unknown case `{case}`"))); + } + let targets = format_targets(s); + s.edit(|d, sel| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let r = t.range.start.min(st.len())..t.range.end.min(st.len()); + let mapped = case_map(st.slice(r.clone()), &case); + // Replace run by run (from the end) so every run keeps its formatting. + let segs: Vec> = + st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect(); + let starts: Vec = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect(); + let mut delta: isize = 0; + for seg in segs.iter().rev() { + let old = st.slice(seg.clone()).to_string(); + let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect(); + if new != old { + delta += new.len() as isize - old.len() as isize; + st.replace(seg.clone(), &new); + } + } + if let Some(ts) = sel.text.as_mut().filter(|ts| ts.story == t.story && ts.cell == t.cell && !ts.is_caret()) { + let end = (r.end as isize + delta).max(r.start as isize) as usize; + if ts.anchor <= ts.focus { + ts.focus = end; + } else { + ts.anchor = end; + } + } + } + Ok(Value::Null) + }) +} + +#[cfg(test)] +mod autocorrect_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn autocorrect_fixes_the_word_before_a_space() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let text = |s: &Session| s.doc().unwrap().doc.stories[&sid].text.clone(); + // Off by default. + for c in ["t", "e", "h", " "] { + s.execute("text.insert", &json!({"text": c})).unwrap(); + } + assert_eq!(text(&s), "teh "); + s.execute("prefs.set", &json!({"autocorrect": true})).unwrap(); + for c in ["T", "e", "h", " ", "c", "a", "t", "."] { + s.execute("text.insert", &json!({"text": c})).unwrap(); + } + assert_eq!(text(&s), "teh The cat."); + // The caret ends after the inserted punctuation. + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "teh The cat.".len()); + } +} + +#[cfg(test)] +mod break_tests { + use serde_json::json; + + use crate::Session; + + /// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one. + #[test] + fn inserted_break_ends_its_paragraph() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap(); + assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree"); + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len()); + // At the end of a paragraph the break takes the place of its return. + let at = story(&s).text.find("\nThree").unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap(); + let st = story(&s); + assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree"); + assert_eq!(st.paras.len(), 3); + st.check().unwrap(); + // Typing after the break goes to the next paragraph; deleting the break or its return + // removes both. + let at = "One\u{e011}".len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree"); + s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree"); + s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap(); + s.execute("text.delete", &json!({"forward": true})).unwrap(); + assert_eq!(story(&s).text, "OnexTwoThree"); + } +} + +#[cfg(test)] +mod language_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn language_sets_quotes_spelling_and_hyphenation() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("type.char", &json!({"attrs": {"language": "German: 2006 Reform"}})).unwrap(); + s.execute("text.insert", &json!({"text": "\"Wort\" ist's"})).unwrap(); + let text = s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].text.clone(); + assert_eq!(text, "\u{201E}Wort\u{201C} ist\u{2019}s"); + // German words aren't English misspellings. + let st = s.execute("spelling.check", &json!({"story": sid})).unwrap(); + assert!(st.as_array().unwrap().is_empty(), "{st}"); + } + + #[test] + fn cjk_languages_have_their_quotes_and_no_english_rules() { + use super::quote_marks; + assert_eq!(quote_marks("Chinese: Simplified"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + // Other spellings of the language names take the same quotes. + assert_eq!(quote_marks("Simplified Chinese"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("zh_CN"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("Chinese (Traditional)"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("zh-TW"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("ja_JP"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] { + assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling"); + } + } + + #[test] + fn locale_coded_languages_take_their_quotes_and_rules() { + use super::quote_marks; + const GERMAN: [char; 4] = ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}']; + const DUTCH: [char; 4] = ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}']; + const GUILLEMETS: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; + const SPANISH: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}']; + const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; + const CORNERS: [char; 4] = ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']; + for (language, quotes, english) in [ + ("de_DE_2006", GERMAN, false), + ("German: 2006 Reform", GERMAN, false), + ("de_CH_2006", GUILLEMETS, false), + ("German: Swiss 2006 Reform", GUILLEMETS, false), + ("nl_NL_2005", DUTCH, false), + ("Dutch: 2005 Reform", DUTCH, false), + ("English: USA", ENGLISH, true), + ("English: UK", ENGLISH, true), + ("en_GB", ENGLISH, true), + ("Spanish: Castilian", SPANISH, false), + ("es_ES", SPANISH, false), + ("French", GUILLEMETS, false), + ("fr_FR", GUILLEMETS, false), + ("nb_NO", GUILLEMETS, false), + ("ja_JP", CORNERS, false), + ("[No Language]", ENGLISH, false), + ] { + assert_eq!(quote_marks(language), quotes, "{language}"); + assert_eq!(designcraft_compose::is_english(language), english, "{language}"); + } + } + + #[test] + fn idml_language_names_keep_their_spelling_and_pick_the_language() { + use std::io::Read; + // A stand-in for Songti SC (first in the Simplified Chinese chain; the real one when installed). + designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("Songti SC", &['直']).unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "直"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap(); + s.execute("type.char", &json!({"attrs": {"language": "Simplified Chinese"}})).unwrap(); + let idml = designcraft_idml::export_idml(&s.doc().unwrap().doc); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml.clone())).unwrap(); + let stories: String = (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).unwrap(); + let mut x = String::new(); + if f.name().starts_with("Stories/") { + f.read_to_string(&mut x).unwrap(); + } + x + }) + .collect(); + assert!(stories.contains(r#"AppliedLanguage="$ID/Simplified Chinese""#), "{stories}"); + // The name comes back as written and sets the text as Simplified Chinese. + let mut back = designcraft_idml::import_idml(&idml).unwrap(); + back.settings.glyph_fallback = true; + let st = back.stories.values().find(|st| st.text.contains('直')).unwrap(); + let (_, base) = back.styles.resolve_para(&st.paras[0]); + assert_eq!(back.styles.resolve_char(&base, st.char_format_at(0)).language, "Simplified Chinese"); + let cs = designcraft_compose::compose_story(&back, st.id, &Default::default()); + let face = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap().face; + assert_eq!(face.family, "Songti SC"); + } +} + +#[cfg(test)] +mod arabic_tests { + use super::*; + #[test] + fn arabic_story_direction_command_validates_and_supports_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "abc"})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let before = s.cache.get(&s.doc().unwrap().doc, sid, None); + s.execute("story.setDirection", &json!({"story": sid.0, "direction": "rightToLeft"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::RightToLeft); + let after = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert!(!std::sync::Arc::ptr_eq(&before, &after)); + assert!(s.execute("story.setDirection", &json!({"story": sid.0, "direction": "unknown"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight); + } +} + +#[cfg(test)] +mod korean_breaks_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn korean_character_breaks_are_a_paragraph_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true)); + s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true)); + } +} + +#[cfg(test)] +mod reserve_tests { + use super::*; + + #[test] + fn failed_reservation_rolls_back_the_edit_even_after_local_text_was_removed() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let result = s.execute("frame.create", &json!({"rect":[36,36,300,300],"content":"text","text":"Keep this text."})).unwrap(); + let sid = StoryId(result["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story":sid.0,"anchor":1,"focus":4})).unwrap(); + let before = s.doc().unwrap().clone(); + assert!(before.selection.text.is_some_and(|t| !t.range().is_empty())); + let journal = s.journal.clone(); + let error = s + .edit(|d, sel| { + let st = d.story_mut(sid).unwrap(); + st.format_chars(5..9, |f| f.over.change = Some(designcraft_doc::ChangeMark::Inserted)); + let at = crate::cmd::changes::mark_deleted(st, 0..9); + assert_eq!(at, 5); + assert_eq!(st.text, "Keep text."); + assert_eq!(st.format_after(0).over.change, Some(designcraft_doc::ChangeMark::Deleted)); + sel.text = None; + reserve_command_text(st, usize::MAX)?; + ok() + }) + .unwrap_err(); + assert!(error.to_string().contains("could not reserve text insertion")); + let after = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&after.doc, &before.doc)); + assert_eq!(after.selection, before.selection); + assert_eq!(after.revision, before.revision); + assert_eq!(after.history.undo.len(), before.history.undo.len()); + assert_eq!(after.history.redo.len(), before.history.redo.len()); + assert_eq!(s.journal, journal); + } +} + +#[cfg(test)] +mod placeholder_tests { + use serde_json::json; + + use crate::Session; + + /// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one + /// paragraph, and the filler ran sentences together: "interit.Ovid"). + #[test] + fn placeholder_text_comes_in_paragraphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap(); + s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap(); + let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); + let text = st["text"].as_str().unwrap(); + let paragraphs: Vec<&str> = text.split('\n').collect(); + assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len()); + for p in ¶graphs { + assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}"); + } + assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}"); + assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]); + assert!(text.len() > 1500, "the frame is filled: {}", text.len()); + } +} diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs new file mode 100644 index 0000000000..0ab69db26d --- /dev/null +++ b/crates/engine/src/cmd/text/format.rs @@ -0,0 +1,1073 @@ +use super::asian::bool_param; +use super::caret::hit_byte_with; +use super::story::selection_attrs; +use super::*; + +/// The Eyedropper with text selected: the formatting of the text under `point` in `frame` goes to +/// the selection, as InDesign's does (character style and overrides on the selected characters, +/// paragraph style and overrides on their paragraphs). +pub(super) fn pick_up_format(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.pickUpFormat"; + let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; + let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; + let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; + let st = s.doc()?; + let sel = st.selection.text.ok_or_else(|| bad(ID, "select the text to format"))?; + let src = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let b = floor_char_boundary(&src.text, b); + // The character under the pointer (at the end of a line, the one before it). + let at = if b >= src.text.len() || src.text[b..].starts_with('\n') { b.saturating_sub(1) } else { b }; + let at = floor_char_boundary(&src.text, at); + let chars = src.char_format_at(at).clone(); + let para = src.paras.get(src.para_at(at)).cloned().ok_or(designcraft_doc::DocError::NoStory(sid))?; + let (a, e) = (sel.anchor.min(sel.focus), sel.anchor.max(sel.focus)); + s.typing_format = None; + s.edit(|d, _| { + let story = d.text_story_mut(sel.story, sel.cell).ok_or(designcraft_doc::DocError::NoStory(sel.story))?; + if e > a { + story.format_chars(a..e, |f| *f = chars.clone()); + } + story.format_paras(a..e, |f| { + f.style = para.style.clone(); + f.para = para.para.clone(); + }); + Ok(json!({"style": chars.style, "paragraphStyle": para.style})) + }) +} + +/// Where a formatting command applies: a story (or a table cell's story) and a byte range. +#[derive(Clone, Debug)] +pub(crate) struct Target { + pub story: StoryId, + pub cell: Option, + pub range: std::ops::Range, +} + +/// Ranges and stories a formatting command applies to: selected table cells (whole cells), the +/// text selection, or whole stories of the selected text frames. +pub(crate) fn format_targets(s: &Session) -> Vec { + let Some(st) = s.active() else { return vec![] }; + if let Some(ts) = st.selection.cells + && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) + { + let mut v = Vec::new(); + for (r, c) in t.selected_owners(ts.range) { + let len = t.cell(r, c).map_or(0, |x| x.text.len()); + v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); + } + return v; + } + if let Some(t) = st.selection.text { + return vec![Target { story: t.story, cell: t.cell, range: t.range() }]; + } + let mut v: Vec = Vec::new(); + for id in &st.selection.items { + if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) { + let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0); + if !v.iter().any(|t| t.story == tf.story) { + v.push(Target { story: tf.story, cell: None, range: 0..len }); + } + } + } + v +} + +/// Character formatting chosen at a bare caret in text: applied to what is typed there next. +#[derive(Clone, Debug)] +pub(crate) struct TypingFormat { + doc: u64, + at: TextSel, + attrs: CharAttrs, + /// Overrides removed (`null` in `type.char`). + cleared: Vec, +} + +/// The typing format for the active document's caret, if it was chosen at this caret. +pub(super) fn typing_format(s: &Session) -> Option<&TypingFormat> { + let st = s.active()?; + let t = st.selection.text?; + s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range()) +} + +/// Is `flag` on in the character format at the start of the first formatting target? +pub(super) fn char_flag_on(s: &Session, flag: impl Fn(&CharAttrs) -> Option) -> bool { + format_targets(s).first().is_some_and(|t| { + s.doc() + .ok() + .and_then(|d| d.doc.text_story(t.story, t.cell)) + .is_some_and(|st| flag(&st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over) == Some(true)) + }) +} + +/// Switch a paragraph attribute of the selected paragraphs: `on`, else the opposite of the +/// first one's. +pub(super) fn para_toggle(s: &mut Session, p: &Value, cmd: &str, attr: &str, get: impl Fn(&designcraft_doc::ParaProps) -> bool) -> Result { + let on = match bool_param(p, "on", cmd)? { + Some(v) => v, + None => !format_targets(s).first().is_some_and(|t| { + s.doc().ok().is_some_and(|d| { + d.doc + .text_story(t.story, t.cell) + .and_then(|st| st.paras.get(st.para_at(t.range.start))) + .is_some_and(|pf| get(&d.doc.styles.resolve_para(pf).0)) + }) + }), + }; + let mut body = serde_json::Map::new(); + body.insert(attr.to_string(), json!(on)); + format_paras(s, &Value::Object(body)) +} + +/// A character fill must name a swatch of the document. +pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { + validate_char_size(attrs, command_id)?; + match &attrs.fill { + Some(fill) => crate::cmd::object::known_swatch(document, fill, command_id, "fill"), + None => Ok(()), + } +} + +/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's +/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative +/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page. +pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> { + match attrs.size { + Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => { + Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt"))) + } + _ => Ok(()), + } +} + +/// Type size limits (InDesign's, and the ones `type.ruby` already uses). +pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; + +pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; + +/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that +/// line, and so the one a line-affecting attribute like leading can land on right away instead of +/// waiting for the next typed character (otherwise an empty line could never get its own leading). +fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool { + let ranges = story.para_ranges(); + ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n') +} + +pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { + let mut a = CharAttrs::default(); + if let Some(o) = attrs.as_object() { + for (k, v) in o { + a.set_json(k, v).map_err(|e| bad("type.char", e))?; + } + } + validate_char_fill(&s.doc()?.doc, &a, "type.char")?; + let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let targets = format_targets(s); + // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it + // also formats the mark right away, since the mark alone sets that line's height; typing there + // still needs the typing format, as new text takes the format of the run before the caret + // (the previous paragraph's mark). + if let [t] = targets.as_slice() + && t.range.is_empty() + && let Some(at) = s.doc()?.selection.text + && let Some(on_mark) = s.doc()?.doc.text_story(t.story, t.cell).filter(|st| !st.is_empty()).map(|st| empty_para_mark(st, t.range.start)) + { + let doc = s.doc()?.uid; + let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); + f.attrs.merge(&a); + f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k)); + f.cleared.extend(cleared.iter().cloned()); + if on_mark { + s.edit(|d, _| { + apply_char_attrs(d, &targets, &a, &cleared); + ok() + })?; + } + s.typing_format = Some(f); + return ok(); + } + s.edit(|d, _| { + apply_char_attrs(d, &targets, &a, &cleared); + ok() + }) +} + +/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the +/// character before it. With no such pair (story or paragraph start, a selection) this is +/// `type.char`. +pub(super) fn kern_pair(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.kernPair"; + let kerning = p.get("kerning").ok_or_else(|| bad(ID, "missing `kerning`"))?; + let targets = format_targets(s); + if let [t] = targets.as_slice() + && t.range.is_empty() + && s.doc()?.selection.text.is_some() + && let Some(start) = s.doc()?.doc.text_story(t.story, t.cell).and_then(|st| pair_start(&st.text, t.range.start)) + { + let mut a = CharAttrs::default(); + a.set_json("kerning", kerning).map_err(|e| bad(ID, e))?; + let cleared = if kerning.is_null() { vec!["kerning".to_string()] } else { vec![] }; + let pair = Target { story: t.story, cell: t.cell, range: start..t.range.start }; + // Kerning chosen earlier for typing at this caret would hide the pair's value. + if typing_format(s).is_some() + && let Some(f) = &mut s.typing_format + { + f.attrs.kerning = None; + f.cleared.retain(|k| k != "kerning"); + } + return s.edit(|d, _| { + apply_char_attrs(d, &[pair], &a, &cleared); + ok() + }); + } + format_chars(s, &json!({ "kerning": kerning })) +} + +/// Byte offset of the character before `pos` when it starts a kerning pair with the character at +/// `pos`: both exist and neither is a paragraph or line break. +fn pair_start(text: &str, pos: usize) -> Option { + let is_text = |c: &char| !matches!(*c, '\n' | designcraft_doc::story::FORCED_LINE_BREAK); + text.get(pos..)?.chars().next().filter(is_text)?; + let c = text.get(..pos)?.chars().next_back().filter(is_text)?; + pos.checked_sub(c.len_utf8()) +} + +/// Character attributes `a` (and the overrides `cleared` removed) over each target's text. +pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { + for t in targets { + let r = &t.range; + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if r.is_empty() { + if st.is_empty() { + // An empty story: its one (empty) run is the format typing takes. + if let Some(run) = st.chars.first_mut() { + run.format.over.merge(a); + st.rev = st.rev.wrapping_add(1); + } + } else if empty_para_mark(st, r.start) { + // An empty paragraph elsewhere in the story: its hidden paragraph mark is its only + // character, and the one that carries line-affecting attributes like leading. + st.format_chars(r.start..r.start + 1, |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } + continue; + } + st.format_chars(r.clone(), |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } +} + +pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { + let empty = serde_json::Map::new(); + let obj = attrs.as_object().unwrap_or(&empty); + // A rule object changes only the rule fields it names, so each paragraph gets its own attrs + // over its resolved rule; this pass validates them. + let mut a = ParaAttrs::default(); + for (k, v) in obj { + a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?; + } + crate::cmd::style::check_grep_styles("type.para", &a)?; + // `null` removes the override (back to the style's value). + let cleared: Vec = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect(); + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + if let Some(st) = d.text_story_mut(t.story, t.cell) { + st.format_paras(t.range.clone(), |p| { + let (current, _) = styles.resolve_para(p); + let mut a = a.clone(); + for (k, v) in obj { + let _ = a.set_json_over(k, v, ¤t); + } + p.para.merge(&a); + for k in &cleared { + let _ = p.para.set_json(k, &Value::Null); + } + }); + } + } + ok() + }) +} + +/// Alt+Left/Right in text: kerning at a caret, tracking of a selection, by the Kerning/Tracking +/// increment. +pub(super) fn kern_step(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::Kerning; + let inc = s.doc()?.doc.settings.kerning_increment; + let inc = if inc.is_finite() { inc.clamp(1.0, 1000.0) } else { 20.0 }; + let amount = match p.get("amount").and_then(Value::as_f64) { + Some(a) => a, + None => p.get("steps").and_then(Value::as_f64).unwrap_or(1.0) * inc, + }; + if !amount.is_finite() { + return Err(bad("type.kernStep", "`amount` must be a number")); + } + let amount = amount.clamp(-11000.0, 11000.0); + // Kerning and tracking range, in 1/1000 em. + let clamp = |v: f64| v.clamp(-1000.0, 10000.0); + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + let mut out = json!({}); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if t.range.is_empty() { + // Manual kerning sits on the character before the caret (it applies after it). + let pos = t.range.start; + let Some(c) = st.text.get(..pos).and_then(|before| before.chars().next_back()) else { continue }; + let at = pos - c.len_utf8(); + let para = st.paras.get(st.para_at(at)).cloned().unwrap_or_default(); + let current = styles.resolve_char(&styles.resolve_para(¶).1, st.format_after(at)).kerning; + // The font's metrics or optical value isn't known here: those start from 0. + let v = clamp(if let Kerning::Manual(v) = current { v } else { 0.0 } + amount); + st.format_chars(at..pos, |f| f.over.kerning = Some(Kerning::Manual(v))); + out = json!({"kerning": v}); + } else { + for (i, pr) in st.para_ranges().into_iter().enumerate() { + let r = pr.start.max(t.range.start)..pr.end.min(t.range.end); + let Some(para) = st.paras.get(i).cloned() else { break }; + if r.is_empty() { + continue; + } + let pc = styles.resolve_para(¶).1; + st.format_chars(r, |f| { + let v = clamp(styles.resolve_char(&pc, f).tracking + amount); + f.over.tracking = Some(v); + out = json!({"tracking": v}); + }); + } + } + } + Ok(out) + }) +} + +pub(super) fn step_size(s: &mut Session, delta: f64) -> Result { + let cur = selection_attrs(s, &json!({}))?; + let size = cur["chars"]["size"].as_f64().unwrap_or(12.0); + format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)})) +} + +pub(super) fn break_link(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::{CharFormat, CharProps, ParaProps}; + let para = match p.get("kind").and_then(Value::as_str).unwrap_or("paragraph") { + "paragraph" => true, + "character" => false, + k => return Err(bad("style.breakLink", format!("unknown kind `{k}`"))), + }; + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let r = t.range.clone(); + if para { + st.format_paras(r, |pf| { + let (pp, pc) = styles.resolve_para(pf); + pf.style = designcraft_doc::styles::NO_PARA_STYLE.into(); + pf.para = ParaProps::default().delta(&pp); + pf.chars = CharProps::default().delta(&pc); + }); + } else { + let ranges = st.para_ranges(); + for (pi, pr) in ranges.into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + if a >= b { + continue; + } + let (_, pc) = styles.resolve_para(&st.paras[pi]); + st.format_chars(a..b, |f| { + let cp = styles.resolve_char(&pc, f); + *f = CharFormat { style: designcraft_doc::story::NO_CHAR_STYLE.into(), over: pc.delta(&cp) }; + }); + } + } + } + ok() + }) +} + +/// The first target character, with any pending caret overrides. Keep character-style +/// inheritance separate from paragraph defaults for style creation from the selection. +pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result { + let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let mut format = + if target.range.is_empty() { story.insert_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); + if let Some(pending) = typing_format(s) + && target.story == pending.at.story + && target.cell == pending.at.cell + && target.range == pending.at.range() + { + apply_typing_format(&mut format, pending); + } + Ok(format) +} + +/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it. +pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) { + let Some(uid) = s.active().map(|st| st.uid) else { return }; + if let Some(pending) = &mut s.typing_format + && pending.doc == uid + { + for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color] + .into_iter() + .flatten() + { + if name == from { + *name = to.to_string(); + } + } + } +} + +fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs { + CharAttrs { + fill: Some(cp.stroke.clone()), + fill_tint: Some(cp.stroke_tint), + stroke: Some(cp.fill.clone()), + stroke_tint: Some(cp.fill_tint), + ..Default::default() + } +} + +/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors. +pub(super) fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result { + const ID: &str = "type.swapFillStroke"; + let targets = format_targets(s); + if let [target] = targets.as_slice() + && target.range.is_empty() + && s.doc()?.selection.text.is_some() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = st.doc.styles.resolve_para(pf); + let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?); + return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint})); + } + let mut edits = Vec::new(); + let d = &s.doc()?.doc; + for target in targets { + let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + if story.is_empty() { + let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0))); + validate_char_fill(d, &attrs, ID)?; + edits.push((target, attrs)); + continue; + } + let mut runs = story.runs().peekable(); + for (pi, para) in story.para_ranges().into_iter().enumerate() { + let start = para.start.max(target.range.start); + let end = para.end.saturating_add(1).min(story.len()).min(target.range.end); + if start >= end { + continue; + } + let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + while let Some((range, format)) = runs.peek() { + if range.end <= start { + runs.next(); + continue; + } + if range.start >= end { + break; + } + let attrs = swapped_colors(&d.styles.resolve_char(&base, format)); + validate_char_fill(d, &attrs, ID)?; + edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs)); + if range.end > end { + break; + } + runs.next(); + } + } + } + s.edit(|d, _| { + for (target, attrs) in &edits { + apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]); + } + ok() + }) +} + +pub(super) fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { + cf.over.merge(&f.attrs); + for k in &f.cleared { + let _ = cf.over.set_json(k, &Value::Null); + } +} + +/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or +/// off as asked, else the opposite of the first selected character. +pub(super) fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { + let id = if bold { "type.bold" } else { "type.italic" }; + let cur = selection_attrs(s, &json!({}))?; + let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string(); + let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string(); + // The document's own fonts count too. + let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); + let (weight, italic) = db.traits_of(&family, &style); + let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic }); + let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) }; + let targets = format_targets(s); + // A caret: the linked style of the format there, for the next typing. + if let [t] = targets.as_slice() + && t.range.is_empty() + { + let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?; + format_chars(s, &json!({"fontStyle": linked}))?; + return Ok(json!({"on": on, "fontStyle": linked})); + } + let mut changed = 0; + let r = s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + for (pi, pr) in st.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end)); + if a >= b { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, pc) = styles.resolve_para(pf); + st.format_chars(a..b, |f| { + let cp = styles.resolve_char(&pc, f); + if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic) + && linked != cp.font_style + { + f.over.font_style = Some(linked); + changed += 1; + } + }); + } + } + // No undo step for nothing. + if changed == 0 { + return Err(bad(id, "nothing to change")); + } + ok() + }); + r?; + Ok(json!({"on": on, "changed": changed})) +} + +pub(crate) fn format_targets_pub(s: &Session) -> Vec { + format_targets(s) +} + +#[cfg(test)] +mod nested_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn nested_and_grep_styles_colour_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Lead", "chars": {"fill": "C=100 M=0 Y=0 K=0"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Num", "chars": {"fill": "C=0 M=100 Y=0 K=0"}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Opening words then 2026 here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.execute( + "type.para", + &json!({"attrs": { + "nestedStyles": [{"style": "Lead", "through": true, "count": 2, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Num", "pattern": "\\d+"}] + }}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); + let text = &d.story(designcraft_doc::StoryId(sid)).unwrap().text; + let fill_at = |byte: usize| { + let g = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.byte == byte).unwrap(); + cs.styles[g.style as usize].fill.clone() + }; + assert_eq!(fill_at(0), "C=100 M=0 Y=0 K=0", "nested: first two words"); + assert_eq!(fill_at(text.find("words").unwrap()), "C=100 M=0 Y=0 K=0"); + assert_eq!(fill_at(text.find("then").unwrap()), "[Black]"); + assert_eq!(fill_at(text.find("2026").unwrap()), "C=0 M=100 Y=0 K=0", "GREP: digits"); + } +} + +#[cfg(test)] +mod style_link_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn bold_and_italic_follow_the_family_and_toggle() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let style_at = |s: &mut Session, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap(); + s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string() + }; + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.italic", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + // Bold over italic and plain text: each run takes its own linked style. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Bold Italic"); + assert_eq!(style_at(&mut s, 7), "Bold"); + // Again: off, back to each run's regular weight. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + assert_eq!(style_at(&mut s, 7), "Regular"); + // Nothing to change: an error and no undo step. + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert!(s.execute("type.bold", &json!({"on": false})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // At a caret the style is for what is typed next. + let end = "plain words here".len(); + s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold"); + s.execute("text.insert", &json!({"text": " now"})).unwrap(); + assert_eq!(style_at(&mut s, end + 1), "Bold"); + assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged"); + // Moving the caret forgets it. + s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0); + } +} + +#[cfg(test)] +mod text_color_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn unnamed_colours_go_to_text_when_asked() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + s.execute("selection.set", &json!({"ids": [fid]})).unwrap(); + let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); + let before = fill_of_frame(&s); + let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap(); + let red = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill"); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str())); + // A text selection takes it without asking. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap(); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str()); + assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour"); + } +} + +#[cfg(test)] +mod nested_line_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn first_line_takes_the_nested_line_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Lead", "chars": {"size": 18}})).unwrap(); + let text = "The opening line of this paragraph reads larger, and the lines after it return to the body size again."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.para", &json!({"nestedLineStyles": [{"style": "Lead", "lines": 1}]})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let lines = &cs.frames[0].lines; + assert!(lines.len() >= 3); + let size_of = |l: usize| cs.styles[lines[l].glyphs.iter().find(|g| g.visible).unwrap().style as usize].size; + assert_eq!(size_of(0), 18.0, "the first line"); + assert!( + lines[0].glyphs.iter().filter(|g| g.visible && g.adv > 0.0).all(|g| cs.styles[g.style as usize].size == 18.0), + "the whole first line" + ); + assert_eq!(size_of(1), 12.0, "the next lines"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + let st = back.stories.values().find(|st| st.text.starts_with("The opening")).unwrap(); + let nl = st.paras[0].para.nested_line_styles.clone().unwrap_or_default(); + assert_eq!((nl.len(), nl.first().map(|n| n.lines)), (1, Some(1)), "IDML AllNestedLineStyles"); + assert_eq!(nl[0].style, "Lead"); + } +} + +#[cfg(test)] +mod rule_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn a_partial_rule_edit_keeps_the_other_fields() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0}); + s.execute("style.paragraph.create", &json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Ruled"})).unwrap(); + // Local: only the colour changes; the rest is inherited from the style. + s.execute("type.para", &json!({"attrs": {"ruleBelow": {"color": "[Registration]"}}})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let mut want = rule.clone(); + want["color"] = json!("[Registration]"); + assert_eq!(attrs["para"]["ruleBelow"], want); + assert_eq!(attrs["para"]["ruleAbove"]["on"], false, "the other rule is untouched"); + // Style: only the weight changes. + s.execute("style.paragraph.edit", &json!({"name": "Ruled", "para": {"ruleBelow": {"weight": 1.5}}})).unwrap(); + let st = s.doc().unwrap().doc.styles.para("Ruled").cloned().unwrap(); + let below = serde_json::to_value(st.para.rule_below.unwrap()).unwrap(); + let mut want = rule.clone(); + want["weight"] = json!(1.5); + assert_eq!(below, want); + assert!(s.execute("type.para", &json!({"attrs": {"ruleBelow": {"thickness": 2}}})).is_err(), "unknown rule field"); + } + + #[test] + fn rule_above_width_follows_column_or_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Short"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mut widths = vec![]; + for column in [true, false] { + s.execute("type.para", &json!({"ruleAbove": {"on": true, "columnWidth": column}})).unwrap(); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + widths.push(cs.frames[0].decos[0].rect.width()); + } + assert!((widths[0] - 328.0).abs() < 1e-6, "column: {widths:?}"); + assert!(widths[1] < 100.0, "text: {widths:?}"); + } +} + +#[cfg(test)] +mod border_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn paragraph_border_and_shading_follow_each_column() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "word ".repeat(80); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 160], "content": "text", "text": text, "caret": false})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.para", &json!({"borderOn": true, "borderWeights": [2, 1, 2, 1], "borderOffsets": [4, 4, 4, 4], "borderColor": "[Black]", "shadingOn": true, "shadingOffsets": [4, 4, 4, 4]})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let decos = &cs.frames[0].decos; + let shading: Vec<_> = decos.iter().filter(|d| d.tint < 0.5).collect(); + assert_eq!(shading.len(), 2, "one shaded box per column"); + let edges = decos.len() - shading.len(); + assert_eq!(edges, 6, "top on the first part, bottom on the last, sides on both"); + let top = decos.iter().filter(|d| d.tint >= 0.5).map(|d| d.rect).fold(f64::MAX, |a, r| a.min(r.y0)); + let first_line = &cs.frames[0].lines[0]; + assert!((top - (first_line.baseline - first_line.ascent - 4.0 - 2.0)).abs() < 1e-6, "offset 4 + weight 2 above the text"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + let p = &back.stories.values().find(|st| st.text.starts_with("word")).unwrap().paras[0].para; + assert_eq!( + (p.border_on, p.border_weights, p.shading_offsets), + (Some(true), Some([2.0, 1.0, 2.0, 1.0]), Some([4.0; 4])), + "IDML paragraph border" + ); + } +} + +#[cfg(test)] +mod gradient_text_tests { + use serde_json::json; + + /// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns + /// `x0..x1`. + fn ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 50.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + /// Red ink at the frame's left, blue at its right. + fn check(img: &designcraft_render::Rendered, what: &str) { + let (lr, lb) = ink(img, 0, 60); + let (rr, rb) = ink(img, 200, 300); + assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + + #[test] + fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + // A gradient swatch (an off-page object's unnamed one), red → blue. + let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) + .unwrap(); + let id = designcraft_doc::ItemId(shape.as_u64().unwrap()); + let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap(); + s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen"); + // The PDF, placed back and drawn: the same gradient. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "text.pdf", "x": 0, "y": 0, "width": 300})).unwrap(); + let d = p.doc().unwrap().doc.clone(); + check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); + } + + /// The columns the red → blue ink spans (black table edges left out). + fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) { + let cols: Vec = (0..img.width) + .filter(|&x| { + (0..img.height).any(|y| { + let p = img.pixel(x, y); + p[1] < 100 && p[0].max(p[2]) > 100 + }) + }) + .collect(); + (*cols.first().unwrap(), *cols.last().unwrap()) + } + + /// Mean red and blue of the red → blue ink in columns `x0..x1`. + fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 && p[0].max(p[2]) > 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 20.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + #[test] + fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + let g = s + .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) + .unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); + // A third of the frame's width: the cell's text, red → blue. + let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone(); + let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap()); + let text = &mut t.cell_mut(0, 0).unwrap().text; + text.insert(0, "HHHH"); + text.format_chars(0..4, |f| { + f.over.fill = Some(g.clone()); + f.over.size = Some(24.0); + }); + let mut rr = designcraft_render::Renderer::new(); + let cache = designcraft_compose::Cache::new(); + let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300})).unwrap(); + let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (x0, x1) = ink_span(&screen); + assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}"); + let q = (x1 - x0) / 4; + for (what, img) in [("screen", &screen), ("pdf", &placed)] { + let (lr, lb) = coloured_ink(img, x0, x0 + q); + let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1); + assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + } +} + +#[cfg(test)] +mod kern_pair_tests { + use designcraft_doc::{Kerning, StoryId}; + use serde_json::json; + + use crate::Session; + + fn avatar() -> (Session, StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AVATAR"})).unwrap(); + (s, StoryId(r["story"].as_u64().unwrap())) + } + + fn kern_at(s: &Session, story: StoryId, pos: usize) -> Option { + s.doc().unwrap().doc.story(story).unwrap().format_after(pos).over.kerning + } + + #[test] + fn kerning_at_a_caret_kerns_the_pair_in_one_undo_step() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::Manual(-80.0)), "A, before the caret, spaces the A|V pair"); + assert_eq!(kern_at(&s, sid, 1), None); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let shown = s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["kerning"].clone(); + assert_eq!(shown, json!({"kind": "manual", "value": -80.0}), "the caret shows the pair's kerning"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), None); + } + + #[test] + fn kerning_a_selection_or_at_the_story_start_works_like_type_char() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2, "focus": 4})).unwrap(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": 20}})).unwrap(); + let kerned: Vec<_> = (0..6).map(|i| kern_at(&s, sid, i)).collect(); + let m = Some(Kerning::Manual(20.0)); + assert_eq!(kerned, [None, None, m, m, None, None]); + // No character before the caret: the value is for the next typing. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "none"}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(kern_at(&s, sid, 0), None); + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::None)); + } + + #[test] + fn kerning_at_the_end_of_a_paragraph_or_story_leaves_the_text_alone() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AV\nTA"})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + for caret in [2, 5] { + s.execute("text.select", &json!({"story": sid.0, "anchor": caret, "focus": caret})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no pair at {caret}: nothing is edited"); + assert_eq!((kern_at(&s, sid, 1), kern_at(&s, sid, 4)), (None, None), "caret {caret}"); + } + } +} + +#[cfg(test)] +mod pick_up_format_tests { + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// Typefool's video (12:04): select body text, Eyedropper, click the third header: the body + /// text takes the header's font, size and paragraph settings. DesignCraft copied the frame's + /// fill and stroke instead. + #[test] + fn the_eyedropper_copies_text_formatting_to_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let head = s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Innovative Design"})).unwrap(); + s.execute("text.select", &json!({"story": head["story"], "anchor": 0, "focus": 17})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold", "size": 18, "tracking": 20}})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center", "spaceAfter": 6}})).unwrap(); + let body = s.execute("frame.create", &json!({"rect": [36, 200, 400, 300], "content": "text", "text": "Body copy here.\nSecond."})).unwrap(); + s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); + // Click the header's middle (the frame's text starts at its top left). + let r = s.execute("type.pickUpFormat", &json!({"frame": head["id"], "point": [100, 50]})).unwrap(); + assert!(r.is_object(), "{r}"); + s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); + let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!((&a["chars"]["fontStyle"], &a["chars"]["size"], &a["chars"]["tracking"]), (&json!("Bold"), &json!(18.0), &json!(20.0)), "{a}"); + assert_eq!((&a["para"]["align"], &a["para"]["spaceAfter"]), (&json!("center"), &json!(6.0))); + // Only the selected characters change; the second paragraph keeps its own. + s.execute("text.select", &json!({"story": body["story"], "anchor": 6, "focus": 10})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], json!(12.0)); + s.execute("text.select", &json!({"story": body["story"], "anchor": 17, "focus": 20})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], json!("left")); + // Not a text frame, or nothing selected: refused. + let rect = s.execute("frame.create", &json!({"rect": [500, 500, 520, 520]})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("type.pickUpFormat", &json!({"frame": rect, "point": [510, 510]})).is_err()); + let _ = ItemId(rect); + } +} + +#[cfg(test)] +mod size_bounds_tests { + use serde_json::json; + + use crate::Session; + + /// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative + /// size was accepted the same way, and 1e9 pt asked the composer for a line a million times + /// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt. + #[test] + fn a_type_size_outside_the_range_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + let select = |s: &mut Session| { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); + }; + for size in [0.0, -12.0, 0.05, 1297.0, 1e9] { + select(&mut s); + let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string(); + assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}"); + } + // The sizes in range still apply, including the ends. + for size in [0.1, 12.0, 1296.0] { + select(&mut s); + s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}")); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size)); + } + // And the styles that carry character attributes are checked the same way. + for (command, params) in [ + ("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})), + ("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})), + ] { + let e = s.execute(command, ¶ms).expect_err("refused").to_string(); + assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}"); + } + } +} diff --git a/crates/engine/src/cmd/text/opentype.rs b/crates/engine/src/cmd/text/opentype.rs new file mode 100644 index 0000000000..9808a94f49 --- /dev/null +++ b/crates/engine/src/cmd/text/opentype.rs @@ -0,0 +1,425 @@ +use super::format::{Target, format_chars, format_targets}; +use super::story::selection_attrs; +#[cfg(test)] +use super::story::{SELECTED_TEXT_CHARS, selected_text_preview}; +use super::*; + +pub(super) fn open_type(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::otf; + let cur = selection_attrs(s, &json!({}))?; + let mut list: Vec = + cur["chars"]["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + if let Some(tag) = p.get("feature").and_then(Value::as_str) { + if tag.is_empty() || tag.len() > 4 || !tag.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(bad("type.openType", format!("bad feature tag `{tag}`"))); + } + let on = p.get("on").and_then(Value::as_bool).unwrap_or(!otf::is_on(&list, tag)); + otf::set(&mut list, tag, on); + } + if let Some(f) = p.get("figures").and_then(Value::as_str) + && !otf::set_figures(&mut list, f) + { + return Err(bad("type.openType", format!("unknown figure style `{f}`"))); + } + if let Some(sets) = p.get("stylisticSets").and_then(Value::as_array) { + let mask = sets.iter().filter_map(Value::as_u64).filter(|n| (1..=20).contains(n)).fold(0u32, |m, n| m | 1 << (n - 1)); + otf::set_stylistic_sets(&mut list, mask); + } + format_chars(s, &json!({"otfFeatures": list}))?; + Ok(json!({"features": list, "figures": otf::figures(&list)})) +} + +/// One stretch of the selection: its length in bytes, the font it is set in and its features. +struct FeatureSegment { + len: usize, + font: (String, String), + applied: Vec, + ligatures: bool, +} + +/// The OpenType features of the fonts in the selection, with how much of the selection each one is +/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`, +/// `some` or `all`. +pub(super) fn open_type_features(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mut segments: Vec = Vec::new(); + for Target { story: sid, cell, range: r } in format_targets(s) { + let Some(story) = st.doc.text_story(sid, cell) else { continue }; + let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() { + vec![(r.start, 1, story.format_after(r.start).clone())] + } else { + story + .runs() + .filter(|(run, _)| run.start < r.end && run.end > r.start) + .map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone())) + .collect() + }; + for (pos, len, cf) in covered { + let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; + let (_, base) = st.doc.styles.resolve_para(para); + let cp = st.doc.styles.resolve_char(&base, &cf); + segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); + } + } + if segments.is_empty() { + let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); + let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); + segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); + } + let db = designcraft_fonts::FontDb::global(); + let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); + for seg in &segments { + fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1))); + } + let total: usize = segments.iter().map(|seg| seg.len).sum::().max(1); + let state = |covered: usize| match covered { + 0 => "none", + n if n >= total => "all", + _ => "some", + }; + // Bytes the feature is applied to, and bytes whose font offers it. + let measure = |tag: &str| { + let (mut applied, mut available) = (0, 0); + for seg in &segments { + // Standard ligatures are the `ligatures` attribute, which shaping reads, unless the + // feature list turns `liga` off. + let on = designcraft_doc::otf::is_on(&seg.applied, tag) && (tag != "liga" || seg.ligatures); + if on { + applied += seg.len; + } + if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { + available += seg.len; + } + } + (state(applied), state(available)) + }; + let tags: std::collections::BTreeSet = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect(); + let mut features = Vec::new(); + for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::().is_ok())) { + let (applied, available) = measure(tag); + features.push(json!({"tag": tag, "applied": applied, "available": available})); + } + // A stylistic set's name is shared when every font that offers it gives it the same name. + let mut sets = Vec::new(); + for set in 1..=20u32 { + let tag = format!("ss{set:02}"); + if !tags.contains(&tag) { + continue; + } + let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); + let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None }; + let (applied, available) = measure(&tag); + sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); + } + let used: Vec = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect(); + Ok(json!({"fonts": used, "features": features, "stylisticSets": sets})) +} + +/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they +/// are on. Each run keeps its other features, whichever font it is set in, and the change is one +/// undo step. +pub(super) fn set_features(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.feature"; + let changes: Vec<(String, bool)> = p + .get("features") + .and_then(Value::as_object) + .ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))? + .iter() + .map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?))) + .collect::>()?; + let targets: Vec = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select text")); + } + let styles = s.doc()?.doc.styles.clone(); + s.edit(|d, _| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let runs: Vec<(std::ops::Range, designcraft_doc::story::CharFormat)> = + st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect(); + for (run, format) in runs { + let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue }; + let (_, base) = styles.resolve_para(¶); + let mut list = styles.resolve_char(&base, &format).otf_features; + for (tag, on) in &changes { + designcraft_doc::otf::set(&mut list, tag, *on); + } + let range = run.start.max(t.range.start)..run.end.min(t.range.end); + st.format_chars(range, |f| f.over.otf_features = Some(list.clone())); + } + } + ok() + }) +} + +#[cfg(test)] +mod open_type_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn text_selection_stays_on_utf8_boundaries() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); + let sid = r["story"].clone(); + s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 4})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 2)); + s.execute("text.select", &json!({"story": sid, "anchor": 4})).unwrap(); + s.execute("text.delete", &json!({"forward": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(t.story).unwrap().text, "é🙂x"); + s.execute("text.select", &json!({"story": sid, "anchor": 2})).unwrap(); + s.execute("story.setText", &json!({"story": sid, "text": "漢字"})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 0)); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 3); + } + + #[test] + fn replacing_a_range_stays_on_utf8_boundaries() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); + let sid = r["story"].clone(); + let story = designcraft_doc::StoryId(sid.as_u64().unwrap()); + // Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing. + for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] { + let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"})); + assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x"); + } + s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x"); + // A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone. + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + s.execute("story.replaceRange", &json!({"story": sid, "start": 0, "end": 2, "text": ""})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.unwrap(); + let text = &st.doc.story(t.story).unwrap().text; + assert_eq!(text, "字🙂x"); + assert!(text.is_char_boundary(t.anchor) && text.is_char_boundary(t.focus), "caret {t:?} in {text:?}"); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + } + + #[test] + fn selected_text_preview_stops_at_its_character_budget() { + let read = std::cell::Cell::new(0usize); + let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; + let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); + let preview = super::selected_text_preview(chars); + assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); + assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); + assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); + } + + #[test] + fn selection_attrs_report_the_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue +What's new in typography this season, and why it matters to every designer"}), + ) + .unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); + let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); + assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); + } + + #[test] + fn open_type_toggles_reach_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Office 1/2 0"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 12})).unwrap(); + let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); + assert_eq!(r["features"], json!(["dlig"])); + let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); + assert_eq!(r["features"], json!([]), "toggles off"); + let r = s.execute("type.openType", &json!({"figures": "proportionalOldstyle", "stylisticSets": [1, 2]})).unwrap(); + assert_eq!(r["figures"], "proportionalOldstyle"); + let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let f: Vec<&str> = a["chars"]["otfFeatures"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); + assert!(f.contains(&"onum") && f.contains(&"ss02"), "{f:?}"); + assert!(s.execute("type.openType", &json!({"feature": "bad tag!"})).is_err()); + } + + #[test] + fn break_link_to_paragraph_and_character_styles() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head", "para": {"spaceBefore": 6}, "chars": {"size": 24}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Em", "chars": {"fontStyle": "Italic"}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Title here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Em"})).unwrap(); + let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); + s.execute("style.breakLink", &json!({"kind": "character"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); + s.execute("style.breakLink", &json!({"kind": "paragraph"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(after["paragraphStyle"], "[No Paragraph Style]"); + assert_eq!(after["characterStyle"], "[None]"); + assert_eq!(after["chars"], before["chars"], "same look"); + assert_eq!(after["para"], before["para"]); + } + + /// Old-style figures shape to other glyphs than the default lining ones (Source Serif 4). + #[test] + fn oldstyle_figures_change_the_glyphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "2024"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let gids = |s: &Session| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames[0].lines[0].glyphs.iter().map(|g| g.gid).collect::>() + }; + let lining = gids(&s); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap(); + assert_ne!(gids(&s), lining); + } + + /// A blank line (an empty paragraph) has no characters to select, but its hidden paragraph + /// mark can still take a leading change right away: otherwise there'd be no way to adjust the + /// line height of a blank line at all. + #[test] + fn leading_applies_to_an_empty_lines_hidden_paragraph_mark() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // The blank line between the two paragraphs: a collapsed caret, since it has no text. + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"leading": {"kind": "points", "value": 40.0}}})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + // `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so + // check just past the mark's own one-byte run, which the mark alone occupies. + assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0))); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap(); + assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed"); + } + + /// Formatting chosen on a blank line is also what gets typed there, not the format of the + /// previous paragraph's mark that new text would otherwise take. + #[test] + fn text_typed_on_a_blank_line_takes_the_format_set_there() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + assert_eq!(st.text, "Para one\nMiddle\nPara two"); + for b in blank..blank + "Middle\n".len() { + assert_eq!(st.format_after(b).over.size, Some(30.0), "byte {b}"); + } + assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); + } + + /// A blank line keeps its format for typing after the caret has left it: text typed on it + /// takes its paragraph mark's format, with no pending typing format. + #[test] + fn a_blank_lines_mark_format_is_typed_after_the_caret_returns() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + assert!(s.typing_format.is_none()); + s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + assert_eq!(st.text, "Para one\nMiddle\nPara two"); + assert_eq!(st.format_after(blank).over.size, Some(30.0)); + assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); + assert_eq!(st.format_after(2).over.size, None); + } +} + +#[cfg(test)] +mod open_type_features_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value { + v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null) + } + + #[test] + fn the_selected_fonts_features_are_listed_with_their_state() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}"); + assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}"); + assert_eq!(feature(&v, "kern")["available"], "all", "{v}"); + } + + #[test] + fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap(); + let story = r["story"].clone(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}"); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); + } + + #[test] + fn standard_ligatures_follow_the_ligatures_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "office"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 6})).unwrap(); + s.execute("type.char", &json!({"attrs": {"ligatures": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "Character › Ligatures off: {v}"); + s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"liga": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "-liga in the feature list: {v}"); + } +} diff --git a/crates/engine/src/cmd/text/story.rs b/crates/engine/src/cmd/text/story.rs new file mode 100644 index 0000000000..c096280ead --- /dev/null +++ b/crates/engine/src/cmd/text/story.rs @@ -0,0 +1,455 @@ +use super::caret::story_of; +use super::format::{Target, effective_char_format, format_targets}; +use super::*; + +/// The stories of the text selection, the selected cells and the selected text frames. +fn selected_stories(s: &Session) -> Result> { + let st = s.doc()?; + let mut stories: Vec = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect(); + stories.dedup(); + for id in &st.selection.items { + if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + } + Ok(stories) +} + +/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size). +pub(super) fn story_options(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "story.options"; + let on = match p.get("opticalMargin") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?), + }; + let size = match p.get("opticalMarginSize") { + None => None, + Some(v) => Some( + v.as_f64() + .and_then(designcraft_doc::OpticalMargin::clamp_size) + .ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?, + ), + }; + if on.is_none() && size.is_none() { + return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize")); + } + let explicit = |key| p.get(key).filter(|v| !v.is_null()); + let stories = if explicit("story").is_some() || explicit("frame").is_some() { + // Explicit targets must not silently fall back to the current selection. + let doc = &s.doc()?.doc; + for key in ["story", "frame"] { + if let Some(value) = explicit(key) { + let id = value.as_u64().ok_or_else(|| bad(CMD, format!("`{key}` must be an unsigned integer")))?; + let sid = if key == "story" { + StoryId(id) + } else { + let item = doc.item(ItemId(id)).ok_or_else(|| bad(CMD, format!("no such frame {id}")))?; + item.text_frame().ok_or_else(|| bad(CMD, format!("frame {id} holds no text")))?.story + }; + if doc.story(sid).is_none() { + return Err(bad(CMD, format!("no such story {}", sid.0))); + } + } + } + vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?] + } else { + selected_stories(s)? + }; + if stories.is_empty() { + return Err(bad(CMD, "no story: select text or a text frame, or give story or frame")); + } + s.edit(|d, _| { + for sid in &stories { + let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?; + let mut om = st.optical_margin; + om.on = on.unwrap_or(om.on); + om.size = size.unwrap_or(om.size); + if om != st.optical_margin { + st.optical_margin = om; + st.rev = st.rev.wrapping_add(1); + } + } + Ok(json!({"stories": stories.len()})) + }) +} + +/// Type ▸ Story Direction. +pub(super) fn story_direction(s: &mut Session, p: &Value) -> Result { + let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; + let stories = selected_stories(s)?; + s.edit(|d, _| { + set_story_direction(d, &stories, vertical); + Ok(json!({"stories": stories.len()})) + }) +} + +/// Set the direction of `stories` (all their frames turn together). +pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[StoryId], vertical: bool) { + for sid in stories { + if let Some(st) = d.story_mut(*sid) + && st.vertical != vertical + { + st.vertical = vertical; + st.rev = st.rev.wrapping_add(1); + } + } +} + +/// How much of the selected text `type.selectionAttrs` reports. +pub(super) const SELECTED_TEXT_CHARS: usize = 60; + +pub(super) fn selected_text_preview(chars: impl Iterator) -> String { + chars + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .skip_while(|c| c.is_whitespace()) + .take(SELECTED_TEXT_CHARS) + .collect() +} + +pub(super) fn selection_attrs(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let targets = format_targets(s); + let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) }; + let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let pi = story.para_at(r.start); + let pf = &story.paras[pi]; + let (pp, base) = st.doc.styles.resolve_para(pf); + let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; + let cp = st.doc.styles.resolve_char(&base, &cf); + let (style_para, style_chars) = st.doc.styles.resolve_para_style(&pf.style); + let mut character_base = base.clone(); + let character_style = st.doc.styles.char_style_attrs(&cf.style); + character_base.apply(&character_style); + let char_delta = character_base.delta(&cp); + let style_keys = character_style.keys(); + let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); + // The selected text (its start, on one line), for previews such as the font menu's. + let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); + let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, + "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), + "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, + "selectedText": selected.trim()}); + let mut stories = selected_stories(s)?; + if stories.is_empty() { + stories.push(sid); + } + out["storyOptions"] = story_options_of(&st.doc, &stories); + if bool_or(p, "mixed", false) { + out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets)); + } + Ok(out) +} + +/// The story options of `stories`, as story.options names them; a value that differs between +/// the stories is null. +fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value { + let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin); + let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) }; + let (mut on, mut size) = (Some(first.on), Some(first.size)); + for om in values { + on = on.filter(|v| *v == om.on); + size = size.filter(|v| *v == om.size); + } + json!({"opticalMargin": on, "opticalMarginSize": size}) +} + +/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`. +fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec { + let mut first: Option> = None; + let mut mixed = std::collections::BTreeSet::new(); + for t in targets { + let Some(story) = doc.text_story(t.story, t.cell) else { continue }; + let a = story.para_at(t.range.start); + let b = story.para_at(t.range.end.max(t.range.start)); + for pf in story.paras.get(a..=b).unwrap_or(&[]) { + let Ok(Value::Object(v)) = serde_json::to_value(doc.styles.resolve_para(pf).0) else { continue }; + match &first { + None => first = Some(v), + Some(f) => mixed.extend(v.into_iter().filter(|(k, x)| f.get(k) != Some(x)).map(|(k, _)| k)), + } + } + } + mixed.into_iter().collect() +} + +#[cfg(test)] +mod story_options_tests { + use super::*; + + #[test] + fn malformed_explicit_story_targets_do_not_change_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Selected"})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + for target in ["story", "frame"] { + for invalid in [json!("invalid"), json!(-1), json!(1.5), json!(true), json!({}), json!([]), json!(999_999)] { + let mut params = json!({"opticalMargin": true}); + params[target] = invalid; + assert!(s.execute("story.options", ¶ms).is_err(), "{params}"); + assert!(!story(&s, sid).on, "{params} changed the selected story"); + } + } + // Selection fallback still works when neither explicit target is supplied. + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert!(story(&s, sid).on); + s.execute("story.options", &json!({"frame": r["id"], "opticalMargin": false})).unwrap(); + assert!(!story(&s, sid).on); + } + + #[test] + fn story_options_treat_null_targets_as_absent_and_validate_both_explicit_ids() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Explicit"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Selected"})).unwrap(); + let aid = StoryId(a["story"].as_u64().unwrap()); + let bid = StoryId(b["story"].as_u64().unwrap()); + for targets in [json!({"story": null}), json!({"frame": null}), json!({"story": null, "frame": null})] { + let mut p = targets; + p["opticalMargin"] = json!(true); + s.execute("story.options", &p).unwrap(); + assert!(story(&s, bid).on); + assert!(!story(&s, aid).on); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!story(&s, bid).on); + } + for targets in [json!({"story": null, "frame": a["id"]}), json!({"story": a["story"], "frame": null})] { + let mut p = targets; + p["opticalMargin"] = json!(true); + s.execute("story.options", &p).unwrap(); + assert!(story(&s, aid).on); + assert!(!story(&s, bid).on); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Even a frame ignored by story precedence must be valid when explicitly supplied. + let non_text = s.execute("frame.create", &json!({"rect": [400, 0, 500, 100]})).unwrap(); + for (targets, message) in [ + (json!({"frame": 999_999}), "no such frame 999999".to_string()), + (json!({"frame": non_text["id"]}), format!("frame {} holds no text", non_text["id"])), + (json!({"story": 999_999}), "no such story 999999".to_string()), + (json!({"story": a["story"], "frame": 999_999}), "no such frame 999999".to_string()), + ] { + let mut p = targets; + p["opticalMargin"] = json!(true); + assert!(s.execute("story.options", &p).unwrap_err().to_string().contains(&message), "{p}: expected {message}"); + assert!(!story(&s, aid).on); + assert!(!story(&s, bid).on); + } + } + + fn first_x(s: &Session, sid: StoryId) -> f64 { + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + cs.frames[0].lines[0].glyphs[0].x + } + + fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin { + s.doc().unwrap().doc.story(sid).unwrap().optical_margin + } + + #[test] + fn story_options_set_optical_margin_with_one_undo_step_each() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + // The selected frame's story. + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + let hung = first_x(&s, sid); + assert!(hung < flush - 1.0, "{hung} {flush}"); + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap(); + assert_eq!(story(&s, sid).size, 24.0); + assert!(first_x(&s, sid) < hung - 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!story(&s, sid).on); + assert_eq!(first_x(&s, sid), flush); + // Hostile sizes. + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap(); + assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE); + for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] { + assert!(s.execute("story.options", &bad).is_err(), "{bad}"); + } + } + + #[test] + fn ignore_optical_margin_through_type_para_and_paragraph_styles() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert!(first_x(&s, sid) < flush - 1.0); + let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin; + s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap(); + assert_eq!(ignored(&s), Some(true)); + assert_eq!(first_x(&s, sid), flush); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ignored(&s), None); + s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap(); + let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin; + assert!(resolved(&s)); + s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap(); + assert!(!resolved(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(resolved(&s)); + s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap(); + assert_eq!(first_x(&s, sid), flush); + } + + #[test] + fn selection_attrs_report_the_story_options_with_differences_as_null() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap(); + let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone(); + assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0})); + s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story"); + s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null})); + s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0})); + } +} + +#[cfg(test)] +mod story_direction_tests { + use designcraft_doc::{ItemId, StoryId}; + use serde_json::json; + + use crate::Session; + + fn frame(s: &mut Session, rect: [f64; 4], text: &str, vertical: bool) -> (ItemId, StoryId) { + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false, "vertical": vertical})).unwrap(); + (ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap())) + } + + /// Which frames of the story are composed vertically, in thread order. + fn composed(s: &Session, sid: StoryId) -> Vec { + s.cache.get(&s.doc().unwrap().doc, sid, None).frames.iter().map(|f| f.vertical).collect() + } + + #[test] + fn story_direction_turns_every_frame_of_the_story() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", false); + let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + // From the second frame selected, through Type ▸ Story Direction. + s.execute("selection.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("type.storyDirection", &json!({"vertical": true})).unwrap(); + assert_eq!(composed(&s, sid), [true, true]); + // From a caret in the text. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.storyDirection", &json!({"vertical": false})).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + // The frame option sets the frame's story. + s.execute("object.textFrameOptions", &json!({"ids": [a.0], "vertical": true})).unwrap(); + assert_eq!(composed(&s, sid), [true, true]); + // Undo puts it back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + } + + #[test] + fn a_frame_threaded_onto_a_vertical_story_turns_vertical() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", true); + let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); + let c = s.execute("frame.create", &json!({"rect": [450, 72, 550, 400]})).unwrap(); + let c = ItemId(c["id"].as_u64().unwrap()); + s.edit(|d, _| { + d.thread(a, b)?; + Ok(d.thread(b, c)?) + }) + .unwrap(); + assert_eq!(composed(&s, sid), [true, true, true]); + } +} + +#[cfg(test)] +mod story_query_contract_tests { + use serde_json::json; + #[test] + fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story")); + let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap(); + assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], ""); + assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], ""); + s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap(); + assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); + } + + #[test] + fn kenten_shatai_auto_tcy_and_grid_settings_take_checked_values() { + use designcraft_doc::cjk::CharacterAlignment; + use designcraft_doc::cjk_settings::{KentenKind, KentenPosition}; + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦書き12年", "vertical": true})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": "縦書き".len()})).unwrap(); + let chars = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].char_format_at(1).over.clone(); + let para = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].paras[0].para.clone(); + // Any setting turns kenten on; values are clamped and text is capped. + let long = "★".repeat(10_000); + s.execute( + "type.kenten", + &json!({"kind": "custom", "character": long, "size": 1e300, "distance": -1e300, "xScale": 0, "fillTint": 250, "position": "belowLeft"}), + ) + .unwrap(); + let a = chars(&s); + assert_eq!((a.kenten, a.kenten_kind, a.kenten_position), (Some(true), Some(KentenKind::Custom), Some(KentenPosition::BelowLeft))); + assert_eq!(a.kenten_character.as_ref().map(|c| c.chars().count()), Some(designcraft_doc::cjk_settings::KENTEN_MARK_MAX_CHARS)); + assert_eq!( + (a.kenten_size, a.kenten_distance, a.kenten_x_scale, a.kenten_fill_tint), + (Some(Some(1296.0)), Some(-1296.0), Some(0.01), Some(Some(1.0))) + ); + // Bad values change nothing. + for bad in [json!({"kind": "sparkle"}), json!({"size": "big"}), json!({"character": 5}), json!({"on": "yes"}), json!({"overprintFill": 1})] { + assert!(s.execute("type.kenten", &bad).is_err(), "{bad}"); + } + assert_eq!(chars(&s), a); + s.execute("type.kenten", &json!({"on": false})).unwrap(); + assert_eq!((chars(&s).kenten, chars(&s).kenten_kind), (Some(false), Some(KentenKind::Custom)), "off keeps the settings"); + // Shatai. + s.execute("type.shatai", &json!({"magnification": 1e9, "angle": -30, "adjustRotation": true})).unwrap(); + let a = chars(&s); + assert_eq!((a.shatai_magnification, a.shatai_angle, a.shatai_adjust_rotation), (Some(90.0), Some(150.0), Some(true))); + assert!(s.execute("type.shatai", &json!({})).is_err()); + assert!(s.execute("type.shatai", &json!({"angle": [45]})).is_err()); + // Paragraph settings. + s.execute("type.autoTateChuYoko", &json!({"digits": 1e12, "includeRoman": true})).unwrap(); + assert_eq!((para(&s).auto_tcy, para(&s).auto_tcy_include_roman), (Some(designcraft_compose::shape::AUTO_TCY_MAX), Some(true))); + s.execute("type.gridSettings", &json!({"alignment": "emCenter", "firstLineOnly": true, "gyoudori": 1e12, "paragraphGyoudori": true})) + .unwrap(); + let p = para(&s); + assert_eq!((p.grid_align, p.grid_reference), (Some(designcraft_doc::GridAlign::FirstLineOnly), Some(CharacterAlignment::EmCenter))); + assert_eq!((p.grid_gyoudori, p.paragraph_gyoudori), (Some(100), Some(true))); + assert!(s.execute("type.gridSettings", &json!({"alignment": "middle"})).is_err()); + s.execute("type.gridSettings", &json!({"alignment": "none"})).unwrap(); + assert_eq!(para(&s).grid_align, Some(designcraft_doc::GridAlign::None)); + s.execute("type.rotateRoman", &json!({})).unwrap(); + assert_eq!(para(&s).rotate_roman, Some(true)); + s.execute("type.rotateRoman", &json!({})).unwrap(); + assert_eq!(para(&s).rotate_roman, Some(false)); + s.execute("type.romanWordBreak", &json!({"on": true})).unwrap(); + assert_eq!(para(&s).roman_word_break, Some(true)); + // The story still composes with every setting at its limit. + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); + assert!(!cs.frames.is_empty()); + } +} diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 2938af768d..682ccebde0 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -431,361 +431,4 @@ fn define(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - /// A parent-page page-number marker follows Numbering & Section Options: changing a section's - /// start number renames its pages without changing their index or the number of sections, and - /// the marker composed for a page shows the new name. - #[test] - fn parent_page_number_follows_section_renumbering() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) - .unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker); - let shown = |s: &Session, page: usize| -> usize { - let d = s.doc().unwrap().doc.clone(); - let cs = s.cache.get(&d, sid, Some(page)); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(shown(&s, 2), 1, "page 3"); - s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap(); - assert_eq!(s.doc().unwrap().doc.page_name(2), "102"); - assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); - } - - /// A section marker character shows the marker text of the section of the page it is composed - /// for: on each document page a parent-page frame is shown on, and in a frame on a document - /// page. A section without marker text shows nothing; the parent page itself shows "Section". - #[test] - fn section_marker_shows_the_pages_section_marker() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); - s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap(); - let marker = designcraft_doc::story::SECTION_MARKER.to_string(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) - .unwrap(); - let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap(); - let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let shown = |s: &Session, sid, page: Option| -> usize { - let d = s.doc().unwrap().doc.clone(); - let cs = s.cache.get(&d, sid, page); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker"); - assert_eq!(shown(&s, parent, Some(2)), "Intro".len()); - assert_eq!(shown(&s, parent, Some(3)), "Intro".len()); - assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page"); - assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself"); - // Editing the marker recomposes. - s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap(); - assert_eq!(shown(&s, parent, Some(2)), "Appendix".len()); - assert_eq!(shown(&s, own, None), "Appendix".len()); - } - - /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running - /// header is composed for the page it is shown on, not the first page with that name. - #[test] - fn running_header_on_parent_page_with_repeated_page_numbers() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); - s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); - let names: Vec = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect(); - assert_eq!(names, ["1", "2", "1", "2"]); - s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); - // The only heading is on the third page (the second section's page 1). - let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); - s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) - .unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let shown = |page: usize| -> usize { - let cs = s.cache.get(&d, sid, Some(page)); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(shown(0), 0, "no heading on or before the first page"); - assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading"); - assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); - } - - /// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a - /// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold - /// "|" (two instances). Returns the two frames' stories. - fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); - let frame = |s: &mut Session, page: usize, y: f64, text: &str| { - let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap(); - s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story - }; - for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { - let sid = frame(&mut s, page, 200.0, text); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); - } - s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); - let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); - let both = format!("{c}|{c}"); - let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both)); - (s, p1, p3) - } - - /// Convert Variable to Text turns the selected instance into the value it shows on its own - /// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay. - #[test] - fn convert_variable_to_text_uses_the_instances_page() { - let (mut s, p1, p3) = running_heads(); - let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); - let second = c.len_utf8() + 1; - s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap(); - s.execute("variables.convertToText", &json!({})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance"); - assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}")); - assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step"); - } - - /// The menu item is enabled only for a selection that holds an instance: not a caret, not - /// text without one. - #[test] - fn convert_variable_to_text_needs_a_selected_instance() { - let (mut s, p1, _) = running_heads(); - let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); - let bar = c.len_utf8(); - let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok(); - for (anchor, focus) in [(0, 0), (bar, bar + 1)] { - s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap(); - assert!(!enabled(&s), "{anchor}..{focus}"); - } - s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap(); - assert!(enabled(&s)); - } - - /// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3, - /// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this - /// story is on page 1, its anchor on page 3. - fn threaded(s: &mut Session) -> designcraft_doc::StoryId { - let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0; - let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap(); - let from = r["id"].as_u64().unwrap(); - s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story; - let cs = s.cache.get(&d, sid, None); - let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos); - assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3"); - s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap(); - sid - } - - /// An instance selected in a table cell becomes the value on the page of the table's anchor. - #[test] - fn convert_variable_to_text_in_a_table_cell() { - let (mut s, ..) = running_heads(); - let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); - let sid = threaded(&mut s); - s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); - let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); - let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 }; - s.doc_mut().unwrap().selection = - designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) }); - s.execute("variables.convertToText", &json!({})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon"); - } - - /// An instance selected in a footnote becomes the value on the page of the note's reference. - #[test] - fn convert_variable_to_text_in_a_footnote() { - let (mut s, ..) = running_heads(); - let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); - let sid = threaded(&mut s); - s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); - let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id; - let note = designcraft_doc::CellAddr::footnote(id); - let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap(); - s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { - story: sid, - anchor: at, - focus: at + c.len_utf8(), - frame: None, - cell: Some(note), - }); - s.execute("variables.convertToText", &json!({})).unwrap(); - let d = &s.doc().unwrap().doc; - let text = &d.text_story(sid, Some(note)).unwrap().text; - assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}"); - } - - /// Text Variables › Convert to Text turns every instance of the variable into the value on - /// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the - /// variable. - #[test] - fn convert_all_instances_to_text() { - let (mut s, p1, p3) = running_heads(); - let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); - let sid = threaded(&mut s); - let at_end = |s: &mut Session| { - let n = s.doc().unwrap().doc.story(sid).unwrap().len(); - s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap(); - }; - s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); - at_end(&mut s); - s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap(); - at_end(&mut s); - s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); - s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha"); - assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon"); - let st = d.story(sid).unwrap(); - assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell"); - assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text); - assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text); - assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); - } - - /// A caret in a table cell or a footnote after an instance stays after the value Convert to - /// Text writes there, so typing lands after it. - #[test] - fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() { - for footnote in [false, true] { - let (mut s, ..) = running_heads(); - let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); - let sid = threaded(&mut s); - let text = format!("{c}|"); - let at = if footnote { - s.execute("footnote.insert", &json!({"text": text})).unwrap(); - designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id) - } else { - s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); - let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); - designcraft_doc::CellAddr { table, row: 0, col: 0 } - }; - let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len(); - s.doc_mut().unwrap().selection = - designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) }); - s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text; - assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}"); - } - } - - /// Deleting a variable flattens its instances in table cells too and moves the instances of - /// later variables down, so cells keep showing the same text. - #[test] - fn delete_variable_reaches_table_cells() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); - let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; - let a = s.doc().unwrap().doc.variable("A").unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); - let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); - s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); - s.execute("variables.delete", &json!({"name": "A"})).unwrap(); - let d = &s.doc().unwrap().doc; - let t = d.story(sid).unwrap().tables.values().next().unwrap(); - let nb = d.variable("B").unwrap(); - assert_eq!(t.cells[0].text.text, format!("Alpha|{}", var_char(nb).unwrap())); - } - - /// Deleting a variable flattens its instances in footnotes and endnotes too and moves the - /// instances of later variables down. - #[test] - fn delete_variable_reaches_notes() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); - let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; - let a = s.doc().unwrap().doc.variable("A").unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "x"})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); - s.execute("footnote.insert", &json!({"text": text})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("endnote.insert", &json!({"text": text})).unwrap(); - s.execute("variables.delete", &json!({"name": "A"})).unwrap(); - let d = &s.doc().unwrap().doc; - let st = d.story(sid).unwrap(); - let want = format!("Alpha|{}", var_char(d.variable("B").unwrap()).unwrap()); - assert_eq!(st.notes[0].text.text, want); - assert_eq!(st.endnotes[0].text.text, want); - } - - #[test] - fn running_header_on_parent_page_follows_headings() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); - // Headings on pages 1 and 3 (page 2 carries page 1's). - for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { - let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": text})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); - } - s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); - s.execute("variables.define", &json!({"name": "Total", "type": "custom", "text": "of "})).unwrap(); - // A header frame on the parent with " · / ". - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) - .unwrap(); - let fid = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let sid = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); - s.execute("text.insert", &json!({"text": " · "})).unwrap(); - s.execute("variables.insert", &json!({"name": "Last Page Number"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))); - // Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3". - let shown = |page: usize| -> usize { - let cs = s.cache.get(&d, sid, Some(page)); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - let (p1, p2, p3) = (shown(0), shown(1), shown(2)); - assert_eq!(p1, p2, "page 2 carries page 1's header"); - assert_eq!(p3, p1 + 2, "Epsilon is two letters longer than Alpha"); - let idx = s.cache.running_index(&d); - assert_eq!(idx.value("Heading", false, Default::default(), 0), "Alpha"); - assert_eq!(idx.value("Heading", false, Default::default(), 1), "Alpha"); - assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); - let list = s.execute("variables.list", &json!({})).unwrap(); - assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); - // Deleting a variable flattens its instances to text. - s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); - assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); - } -} +mod tests; diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs new file mode 100644 index 0000000000..4a4832c84c --- /dev/null +++ b/crates/engine/src/cmd/variables/tests.rs @@ -0,0 +1,356 @@ +use super::*; + +/// A parent-page page-number marker follows Numbering & Section Options: changing a section's +/// start number renames its pages without changing their index or the number of sections, and +/// the marker composed for a page shows the new name. +#[test] +fn parent_page_number_follows_section_renumbering() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker); + let shown = |s: &Session, page: usize| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, 2), 1, "page 3"); + s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_name(2), "102"); + assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); +} + +/// A section marker character shows the marker text of the section of the page it is composed +/// for: on each document page a parent-page frame is shown on, and in a frame on a document +/// page. A section without marker text shows nothing; the parent page itself shows "Section". +#[test] +fn section_marker_shows_the_pages_section_marker() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap(); + let marker = designcraft_doc::story::SECTION_MARKER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap(); + let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let shown = |s: &Session, sid, page: Option| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, page); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker"); + assert_eq!(shown(&s, parent, Some(2)), "Intro".len()); + assert_eq!(shown(&s, parent, Some(3)), "Intro".len()); + assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page"); + assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself"); + // Editing the marker recomposes. + s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap(); + assert_eq!(shown(&s, parent, Some(2)), "Appendix".len()); + assert_eq!(shown(&s, own, None), "Appendix".len()); +} + +/// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running +/// header is composed for the page it is shown on, not the first page with that name. +#[test] +fn running_header_on_parent_page_with_repeated_page_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + let names: Vec = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect(); + assert_eq!(names, ["1", "2", "1", "2"]); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // The only heading is on the third page (the second section's page 1). + let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let shown = |page: usize| -> usize { + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(0), 0, "no heading on or before the first page"); + assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading"); + assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); +} + +/// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a +/// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold +/// "|" (two instances). Returns the two frames' stories. +fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + let frame = |s: &mut Session, page: usize, y: f64, text: &str| { + let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap(); + s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story + }; + for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { + let sid = frame(&mut s, page, 200.0, text); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let both = format!("{c}|{c}"); + let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both)); + (s, p1, p3) +} + +/// Convert Variable to Text turns the selected instance into the value it shows on its own +/// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay. +#[test] +fn convert_variable_to_text_uses_the_instances_page() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let second = c.len_utf8() + 1; + s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap(); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance"); + assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}")); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step"); +} + +/// The menu item is enabled only for a selection that holds an instance: not a caret, not +/// text without one. +#[test] +fn convert_variable_to_text_needs_a_selected_instance() { + let (mut s, p1, _) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let bar = c.len_utf8(); + let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok(); + for (anchor, focus) in [(0, 0), (bar, bar + 1)] { + s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap(); + assert!(!enabled(&s), "{anchor}..{focus}"); + } + s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap(); + assert!(enabled(&s)); +} + +/// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3, +/// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this +/// story is on page 1, its anchor on page 3. +fn threaded(s: &mut Session) -> designcraft_doc::StoryId { + let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0; + let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap(); + let from = r["id"].as_u64().unwrap(); + s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story; + let cs = s.cache.get(&d, sid, None); + let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos); + assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3"); + s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap(); + sid +} + +/// An instance selected in a table cell becomes the value on the page of the table's anchor. +#[test] +fn convert_variable_to_text_in_a_table_cell() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 }; + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon"); +} + +/// An instance selected in a footnote becomes the value on the page of the note's reference. +#[test] +fn convert_variable_to_text_in_a_footnote() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id; + let note = designcraft_doc::CellAddr::footnote(id); + let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: at, + focus: at + c.len_utf8(), + frame: None, + cell: Some(note), + }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let text = &d.text_story(sid, Some(note)).unwrap().text; + assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}"); +} + +/// Text Variables › Convert to Text turns every instance of the variable into the value on +/// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the +/// variable. +#[test] +fn convert_all_instances_to_text() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let at_end = |s: &mut Session| { + let n = s.doc().unwrap().doc.story(sid).unwrap().len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap(); + }; + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha"); + assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon"); + let st = d.story(sid).unwrap(); + assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell"); + assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text); + assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); +} + +/// A caret in a table cell or a footnote after an instance stays after the value Convert to +/// Text writes there, so typing lands after it. +#[test] +fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() { + for footnote in [false, true] { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let text = format!("{c}|"); + let at = if footnote { + s.execute("footnote.insert", &json!({"text": text})).unwrap(); + designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id) + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + designcraft_doc::CellAddr { table, row: 0, col: 0 } + }; + let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len(); + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) }); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text; + assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}"); + } +} + +/// Deleting a variable flattens its instances in table cells too and moves the instances of +/// later variables down, so cells keep showing the same text. +#[test] +fn delete_variable_reaches_table_cells() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); + let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; + let a = s.doc().unwrap().doc.variable("A").unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); + s.execute("variables.delete", &json!({"name": "A"})).unwrap(); + let d = &s.doc().unwrap().doc; + let t = d.story(sid).unwrap().tables.values().next().unwrap(); + let nb = d.variable("B").unwrap(); + assert_eq!(t.cells[0].text.text, format!("Alpha|{}", var_char(nb).unwrap())); +} + +/// Deleting a variable flattens its instances in footnotes and endnotes too and moves the +/// instances of later variables down. +#[test] +fn delete_variable_reaches_notes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); + let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; + let a = s.doc().unwrap().doc.variable("A").unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "x"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("footnote.insert", &json!({"text": text})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("endnote.insert", &json!({"text": text})).unwrap(); + s.execute("variables.delete", &json!({"name": "A"})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(sid).unwrap(); + let want = format!("Alpha|{}", var_char(d.variable("B").unwrap()).unwrap()); + assert_eq!(st.notes[0].text.text, want); + assert_eq!(st.endnotes[0].text.text, want); +} + +#[test] +fn running_header_on_parent_page_follows_headings() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // Headings on pages 1 and 3 (page 2 carries page 1's). + for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { + let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": text})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + s.execute("variables.define", &json!({"name": "Total", "type": "custom", "text": "of "})).unwrap(); + // A header frame on the parent with " · / ". + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) + .unwrap(); + let fid = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let sid = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); + s.execute("text.insert", &json!({"text": " · "})).unwrap(); + s.execute("variables.insert", &json!({"name": "Last Page Number"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))); + // Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3". + let shown = |page: usize| -> usize { + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + let (p1, p2, p3) = (shown(0), shown(1), shown(2)); + assert_eq!(p1, p2, "page 2 carries page 1's header"); + assert_eq!(p3, p1 + 2, "Epsilon is two letters longer than Alpha"); + let idx = s.cache.running_index(&d); + assert_eq!(idx.value("Heading", false, Default::default(), 0), "Alpha"); + assert_eq!(idx.value("Heading", false, Default::default(), 1), "Alpha"); + assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); + let list = s.execute("variables.list", &json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); + // Deleting a variable flattens its instances to text. + s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); + assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2defc42d84..58301e9e6d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -763,19 +763,3 @@ pub(crate) use push_undo as record_undo; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_autosize; -#[cfg(test)] -mod tests_file_guard; -#[cfg(test)] -mod tests_idml; -#[cfg(test)] -mod tests_ime; -#[cfg(test)] -mod tests_linked_paths; -#[cfg(test)] -mod tests_params; -#[cfg(test)] -mod tests_roots; -#[cfg(test)] -mod tests_table; diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 96bb228e2f..468ce1c5ad 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -945,484 +945,4 @@ pub fn to_svg(latex: &str, size: f64) -> (Vec, (f64, f64)) { } #[cfg(test)] -mod tests { - use super::*; - - // Frozen pre-repair arithmetic; called only for shallow fixtures, never deep input. - /// Frozen recursive layout oracle for shallow test fixtures only, never deep input. - fn recursive_layout_reference(f: &Fonts, n: &Node, size: f64) -> MBox { - match n { - Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), - Node::Text(s) => text_box(f, s, size, false), - Node::Space(em) => MBox { w: em * size, ..Default::default() }, - Node::Group(list) => recursive_group_reference(f, list, size), - Node::Frac(a, b) => recursive_fraction_reference(f, a, b, size), - Node::Sqrt(idx, a) => recursive_sqrt_reference(f, idx, a, size), - Node::Big(op) => recursive_big_reference(f, op, size), - Node::Scripts { base, sup, sub } => recursive_scripts_reference(f, base, sup, sub, size), - Node::Fenced(open, body, close) => recursive_fenced_reference(f, open, body, close, size), - } - } - - /// Oracle for operator spacing, leading-sign treatment and punctuation gaps. - fn recursive_group_reference(f: &Fonts, list: &[Node], size: f64) -> MBox { - let mut parts = Vec::new(); - let mut prev: Option = None; - for (k, item) in list.iter().enumerate() { - let cl = match item { - Node::Sym(_, c) => Some(*c), - Node::Scripts { base, .. } => match &**base { - Node::Sym(_, c) => Some(*c), - _ => None, - }, - _ => None, - }; - // A leading + or − (or after another operator) is a sign, not a binary operator. - let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { - Some(Class::Plain) - } else { - cl - }; - let gap = |c: Option| match c { - Some(Class::Bin) => size * 4.0 / 18.0, - Some(Class::Rel) => size * 5.0 / 18.0, - _ => 0.0, - }; - let mut before = gap(cl).max(gap(prev)); - if k == 0 { - before = 0.0; - } - if prev == Some(Class::Punct) { - before = before.max(size * 3.0 / 18.0); - } - parts.push((recursive_layout_reference(f, item, size), before)); - prev = cl; - } - hbox(parts) - } - - /// Oracle for fraction rule, child scale, math axis, gaps and bounds. - fn recursive_fraction_reference(f: &Fonts, a: &Node, b: &Node, size: f64) -> MBox { - let axis = size * 0.25; - let s2 = size * 0.85; - let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2)); - let w = na.w.max(nb.w) + size * 0.2; - let t = (size * 0.06).max(0.4); - let gap = size * 0.15; - let num_base = -(axis + t / 2.0 + gap + na.desc); - let den_base = -axis + t / 2.0 + gap + nb.asc; - let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; - out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); - let (wa, wb) = (na.w, nb.w); - out.els.extend(na.shifted((w - wa) / 2.0, num_base)); - out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); - out - } - - /// Oracle for radical geometry, body offset and optional root index. - fn recursive_sqrt_reference(f: &Fonts, idx: &Option>, a: &Node, size: f64) -> MBox { - let axis = size * 0.25; - let inner = recursive_layout_reference(f, a, size); - let t = (size * 0.05).max(0.4); - let gap = size * 0.12; - let top = inner.asc + gap + t; - let bottom = inner.desc; - let hook = size * 0.55; - let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; - // The radical: a short rise, the long stroke down, then up to the bar. - out.els.push(Elem::Line { - pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], - w: t, - }); - let iw = inner.w; - let _ = iw; - out.els.extend(inner.shifted(hook, 0.0)); - if let Some(i) = idx { - let ib = recursive_layout_reference(f, i, size * 0.55); - let (ia, iw2) = (ib.asc, ib.w); - out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); - out.asc = out.asc.max(axis + size * 0.1 + ia); - } - out - } - - /// Oracle for enlarged, math-axis-centred big operators. - fn recursive_big_reference(f: &Fonts, op: &str, size: f64) -> MBox { - let mut b = text_box(f, op, size * 1.5, false); - b.asc = size * 1.05; - b.desc = size * 0.35; - for e in &mut b.els { - if let Elem::Text { y, .. } = e { - // Centre the glyph on the math axis. - *y = size * 0.32; - } - } - b - } - - /// Oracle for ordinary sup/sub placement and big-operator limits above and below. - fn recursive_scripts_reference(f: &Fonts, base: &Node, sup: &Option>, sub: &Option>, size: f64) -> MBox { - let big = matches!(base, Node::Big(_)); - let b = recursive_layout_reference(f, base, size); - let s2 = size * 0.7; - let (bw, basc, bdesc) = (b.w, b.asc, b.desc); - let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; - if big { - // Limits above and below. - let mut w = bw; - if let Some(sp) = sup { - let sb = recursive_layout_reference(f, sp, s2); - let y = -basc - size * 0.1 - sb.desc; - w = w.max(sb.w); - let (sw, sa) = (sb.w, sb.asc); - out.els.extend(sb.shifted((bw - sw) / 2.0, y)); - out.asc = out.asc.max(-y + sa); - } - if let Some(sbn) = sub { - let sb = recursive_layout_reference(f, sbn, s2); - let y = bdesc + size * 0.1 + sb.asc; - w = w.max(sb.w); - let (sw, sd) = (sb.w, sb.desc); - out.els.extend(sb.shifted((bw - sw) / 2.0, y)); - out.desc = out.desc.max(y + sd); - } - // Centre the operator over the widest limit. - let dx = (w - bw) / 2.0; - let els = std::mem::take(&mut out.els); - return MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; - } - let mut w = bw; - if let Some(sp) = sup { - let sb = recursive_layout_reference(f, sp, s2); - let y = -(size * 0.42).max(basc - sb.asc * 0.6); - w = w.max(bw + sb.w + size * 0.05); - let sa = sb.asc; - out.els.extend(sb.shifted(bw + size * 0.05, y)); - out.asc = out.asc.max(-y + sa); - } - if let Some(sbn) = sub { - let sb = recursive_layout_reference(f, sbn, s2); - let y = (size * 0.2).max(bdesc * 0.8); - w = w.max(bw + sb.w + size * 0.05); - let sd = sb.desc; - out.els.extend(sb.shifted(bw + size * 0.05, y)); - out.desc = out.desc.max(y + sd); - } - out.w = w; - out - } - - /// Oracle for content-scaled, centred delimiters. - fn recursive_fenced_reference(f: &Fonts, open: &str, body: &Node, close: &str, size: f64) -> MBox { - let inner = recursive_layout_reference(f, body, size); - let h = (inner.asc + inner.desc).max(size); - let k = h / size; - let ds = size * k.max(1.0); - let mk = |s: &str| -> MBox { - if s.is_empty() { - return MBox::default(); - } - let mut b = text_box(f, s, ds, false); - // Scaled delimiters centred on the content. - let mid = (inner.desc - inner.asc) / 2.0; - for e in &mut b.els { - if let Elem::Text { y, .. } = e { - *y = mid + ds * 0.25; - } - } - b.asc = inner.asc.max(size * 0.72); - b.desc = inner.desc.max(size * 0.22); - b - }; - let (l, r) = (mk(open), mk(close)); - hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) - } - - /// Asserts fraction/nested-root, big scripts, relation, symbol, fence and raw `\text` parse shapes. - #[test] - fn parses_the_common_constructs() { - let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}"); - let Node::Group(v) = &n else { panic!() }; - assert!(matches!(&v[0], Node::Frac(_, _))); - let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha"); - let Node::Group(v) = &n else { panic!() }; - assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_)))); - assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel))); - assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident))); - let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x"); - let Node::Group(v) = &n else { panic!() }; - assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")")); - assert!(v.contains(&Node::Text("if ".into()))); - } - - /// A command that merely starts with "right" (`\rightarrow`) must not close a `\left` fence. - #[test] - fn right_prefixed_commands_do_not_close_a_fence() { - let n = parse(r"\left( x \rightarrow y \right)"); - let Node::Group(v) = &n else { panic!() }; - assert_eq!(v.len(), 1, "{v:?}"); - let Node::Fenced(o, body, c) = &v[0] else { panic!("{v:?}") }; - assert!(o == "(" && c == ")", "{v:?}"); - let Node::Group(b) = &**body else { panic!("{v:?}") }; - assert_eq!(*b, vec![Node::Sym("x".into(), Class::Ident), Node::Sym("→".into(), Class::Rel), Node::Sym("y".into(), Class::Ident)]); - // An unknown `\right…` command stays inside the fence too. - let Node::Group(v) = &parse(r"\left[ \rightfoo \right]") else { panic!() }; - assert_eq!(v.len(), 1, "{v:?}"); - assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "[" && c == "]"), "{v:?}"); - // Real closers still close it. - for (src, close) in [(r"\left( x \right)", ")"), (r"\left( x \right.", ""), (r"\left( x \right)y", ")")] { - let Node::Group(v) = &parse(src) else { panic!() }; - assert!(matches!(&v[0], Node::Fenced(_, _, c) if c == close), "{src}: {v:?}"); - } - } - - #[test] - fn layout_sizes_and_svg() { - let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0); - let s = String::from_utf8(svg).unwrap(); - assert!(s.starts_with("E<") && s.contains(">2<")); - assert!(w > 30.0 && h > 10.0, "{w} {h}"); - // A fraction is taller than its numerator alone; a big operator with limits taller still. - let (_, (_, h1)) = to_svg("x", 12.0); - let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0); - let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0); - assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}"); - // The SVG parses and rasterises. - let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); - assert!(designcraft_images::svg_tree(&svg).is_some()); - } - - /// Hostile input must not overflow the stack: nesting past the cap stops descending instead - /// of recursing once per group (100k levels used to abort the process). - #[test] - fn an_over_deep_expression_is_bounded() { - for latex in [ - format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)), - format!("{}x", r"\frac".repeat(100_000)), - format!("{}x", r"\left(".repeat(100_000)), - format!("{}z{}", r"\sqrt{".repeat(1_024), "}".repeat(1_024)), - format!("{}z{}", "x^{".repeat(1_024), "}".repeat(1_024)), - ] { - let (svg, (w, h)) = to_svg(&latex, 12.0); - assert!(!svg.is_empty()); - assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); - } - } - /// Count actual tree shape without recursively comparing or formatting a deep AST. - fn shape(root: &Node) -> ([usize; 6], usize) { - let mut counts = [0; 6]; // groups, fractions, roots, fences, scripts, sentinel z - let mut deepest = 0; - let mut work = vec![(root, 1)]; - while let Some((node, depth)) = work.pop() { - deepest = deepest.max(depth); - match node { - Node::Group(nodes) => { - counts[0] += 1; - work.extend(nodes.iter().map(|node| (node, depth + 1))); - } - Node::Frac(a, b) => { - counts[1] += 1; - work.extend([(a.as_ref(), depth + 1), (b.as_ref(), depth + 1)]); - } - Node::Sqrt(index, body) => { - counts[2] += 1; - work.push((body, depth + 1)); - if let Some(index) = index { - work.push((index, depth + 1)); - } - } - Node::Fenced(_, body, _) => { - counts[3] += 1; - work.push((body, depth + 1)); - } - Node::Scripts { base, sup, sub } => { - counts[4] += 1; - work.push((base, depth + 1)); - if let Some(sup) = sup { - work.push((sup, depth + 1)); - } - if let Some(sub) = sub { - work.push((sub, depth + 1)); - } - } - Node::Sym(s, _) if s == "z" => counts[5] += 1, - _ => {} - } - } - (counts, deepest) - } - - /// With `MAX_NESTING == 256`, all supported 255-level constructs stay nested and still render their sentinel. - #[test] - fn the_full_256_budget_keeps_deep_valid_formulas_intact() { - assert_eq!(MAX_NESTING, 256, "preserve the existing expression capability"); - let groups = MAX_NESTING - 1; - let arguments = (MAX_NESTING - 1) / 2; - let index_groups = MAX_NESTING - 3; - let cases = [ - (format!("{}z{}", "{".repeat(groups), "}".repeat(groups)), [groups + 1, 0, 0, 0, 0, 1], groups + 2), - (format!("{}z{}", r"\left(".repeat(groups), r"\right)".repeat(groups)), [groups + 1, 0, 0, groups, 0, 1], groups * 2 + 2), - (format!("{}z{}", r"\frac{".repeat(arguments), "}{y}".repeat(arguments)), [arguments * 2 + 1, arguments, 0, 0, 0, 1], arguments * 2 + 2), - (format!("{}z{}", r"\sqrt{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, arguments, 0, 0, 1], arguments * 2 + 2), - (format!("{}z{}", "x^{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), - (format!("{}z{}", "x_{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), - (format!(r"\sqrt[{}z{}]{{y}}", "{".repeat(index_groups), "}".repeat(index_groups)), [index_groups + 3, 0, 1, 0, 0, 1], index_groups + 4), - ]; - for (latex, counts, depth) in cases { - assert_eq!(shape(&parse(&latex)), (counts, depth), "all constructs must remain nested, not flatten into siblings"); - let (svg, (w, h)) = to_svg(&latex, 12.0); - assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); - assert!(String::from_utf8(svg).unwrap().contains(">z<"), "the innermost content must render"); - } - } - - /// A capped list consumes nothing; capped root-index/body cursor behavior and the permissive first-`]` grammar remain unchanged. - #[test] - fn cap_and_index_returns_preserve_their_exact_cursor_semantics() { - let chars: Vec<_> = "x".chars().collect(); - let mut p = Parser { s: &chars, i: 0 }; - assert_eq!(p.parse(MAX_NESTING - 1), Node::Group(vec![Node::Sym("x".into(), Class::Ident)])); - assert_eq!(p.i, 1); - let mut p = Parser { s: &chars, i: 0 }; - assert_eq!(p.parse(MAX_NESTING), Node::Group(vec![])); - assert_eq!(p.i, 0, "a capped List does not consume its input"); - // Both the index subparse and radicand still have their entire old depth allowance. - let source = r"\sqrt[x]{y}z"; - assert_eq!( - parse_at(source, MAX_NESTING - 4), - Node::Group(vec![ - Node::Sqrt( - Some(Box::new(Node::Group(vec![Node::Sym("x".into(), Class::Ident)]))), - Box::new(Node::Group(vec![Node::Sym("y".into(), Class::Ident)])) - ), - Node::Sym("z".into(), Class::Ident), - ]) - ); - // At this starting depth the index and braced body are capped. The outer cursor is - // restored after ']', then the old non-consuming body cap leaves y/}/z as siblings. - let chars: Vec<_> = source.chars().collect(); - let mut p = Parser { s: &chars, i: 0 }; - assert_eq!( - p.parse(MAX_NESTING - 2), - Node::Group(vec![ - Node::Sqrt(Some(Box::new(Node::Group(vec![]))), Box::new(Node::Group(vec![]))), - Node::Sym("y".into(), Class::Ident), - Node::Sym("}".into(), Class::Plain), - Node::Sym("z".into(), Class::Ident), - ]) - ); - assert_eq!(p.i, chars.len()); - // Do not change the permissive first-']' index grammar into balanced bracket parsing. - assert_eq!( - parse(r"\sqrt[x[y]z]q"), - Node::Group(vec![ - Node::Sqrt( - Some(Box::new(Node::Group(vec![ - Node::Sym("x".into(), Class::Ident), - Node::Sym("[".into(), Class::Plain), - Node::Sym("y".into(), Class::Ident) - ]))), - Box::new(Node::Sym("z".into(), Class::Ident)) - ), - Node::Sym("]".into(), Class::Plain), - Node::Sym("q".into(), Class::Ident), - ]) - ); - } - - /// Repeated scripts replace slots; a fence `^` stays literal; incomplete fractions, escaped braces and unknown commands retain the prior grammar. - #[test] - fn heap_continuations_preserve_small_grammar_and_script_precedence() { - let sym = |s: &str| Node::Sym(s.into(), Class::Ident); - assert_eq!( - parse("x^{a}_{b}^{c}_{d}"), - Node::Group(vec![Node::Scripts { - base: Box::new(sym("x")), - sup: Some(Box::new(Node::Group(vec![sym("c")]))), - sub: Some(Box::new(Node::Group(vec![sym("d")]))), - }]) - ); - assert_eq!( - parse(r"\left( ^x \right)"), - Node::Group(vec![Node::Fenced("(".into(), Box::new(Node::Group(vec![Node::Sym("^".into(), Class::Plain), sym("x")])), ")".into())]) - ); - assert_eq!(parse(r"\frac{x"), Node::Group(vec![Node::Frac(Box::new(Node::Group(vec![sym("x")])), Box::new(Node::Group(vec![])))])); - assert_eq!( - parse(r"\text{a{b}}\{\}\_\unknown"), - Node::Group(vec![ - Node::Text("a{b}".into()), - Node::Sym("{".into(), Class::Punct), - Node::Sym("}".into(), Class::Punct), - Node::Sym("_".into(), Class::Plain), - Node::Text(r"\unknown".into()) - ]) - ); - } - - /// Iterative layout equals the frozen recursive oracle for representative formulas, including malformed inputs. - #[test] - fn heap_layout_preserves_the_existing_element_order_and_geometry() { - let db = FontDb::global(); - let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") }; - for latex in [ - "E = mc^2", - "x^{a}_{b}^{c}_{d}", - r"\frac{a^2+b}{\sqrt[3]{x}}", - r"\sum_{i=0}^{n} i^2", - r"\int_a^b x", - r"\left( x \rightarrow y \right) + \text{if } x", - r"\left. x \right]", - r"-x,+y;\quad z", - r"\sqrt[x[y]z]q", - r"\frac{x", - r"\left( ^x \right)", - ] { - let tree = parse(latex); - let actual = layout(&f, &tree, 12.0); - let expected = recursive_layout_reference(&f, &tree, 12.0); - assert_eq!((actual.w, actual.asc, actual.desc, actual.els), (expected.w, expected.asc, expected.desc, expected.els), "{latex}"); - } - } - - /// Build a deep five-way alternating AST for destructor stress tests. - fn mixed_chain(depth: usize) -> Node { - let mut node = Node::Sym("z".into(), Class::Ident); - for i in 0..depth { - node = match i % 5 { - 0 => Node::Group(vec![node]), - 1 => Node::Frac(Box::new(node), Box::new(Node::Text("d".into()))), - 2 => Node::Sqrt(Some(Box::new(Node::Text("3".into()))), Box::new(node)), - 3 => Node::Scripts { base: Box::new(node), sup: Some(Box::new(Node::Text("p".into()))), sub: Some(Box::new(Node::Text("q".into()))) }, - _ => Node::Fenced("(".into(), Box::new(node), ")".into()), - }; - } - node - } - - /// Drops 50k-node chains, replaces one script slot, and unwinds parser continuations while preserving the retained slot. - #[test] - fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() { - // Far beyond the parser budget: this directly proves the private destructor's traversal. - drop(mixed_chain(50_000)); - for up in [false, true] { - let old = Box::new(mixed_chain(50_000)); - let other = Box::new(Node::Text("other".into())); - let (sup, sub) = if up { (Some(old), Some(other)) } else { (Some(other), Some(old)) }; - let root = attach_script(Node::Scripts { base: Box::new(Node::Big("∑".into())), sup, sub }, Node::Text("new".into()), up); - let Node::Scripts { base, sup, sub } = &root else { panic!("script wrapper") }; - assert!(matches!(&**base, Node::Big(s) if s == "∑")); - let (replaced, retained) = if up { (sup, sub) } else { (sub, sup) }; - assert!(matches!(replaced.as_deref(), Some(Node::Text(s)) if s == "new")); - assert!(matches!(retained.as_deref(), Some(Node::Text(s)) if s == "other")); - } - let result = std::panic::catch_unwind(|| { - // Actual continuation variants own completed children while awaiting later input. - let _partial = [ - ParseWork::FractionDenominator { numerator: mixed_chain(25_000) }, - ParseWork::SqrtArgument { index: Some(Box::new(mixed_chain(25_000))) }, - ]; - panic!("exercise partial-parser-state unwinding"); - }); - assert!(result.is_err(), "the intentional panic must actually unwind the owned frames"); - } -} +mod tests; diff --git a/crates/engine/src/math/tests.rs b/crates/engine/src/math/tests.rs new file mode 100644 index 0000000000..9d1e75884f --- /dev/null +++ b/crates/engine/src/math/tests.rs @@ -0,0 +1,476 @@ +use super::*; + +// Frozen pre-repair arithmetic; called only for shallow fixtures, never deep input. +/// Frozen recursive layout oracle for shallow test fixtures only, never deep input. +fn recursive_layout_reference(f: &Fonts, n: &Node, size: f64) -> MBox { + match n { + Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), + Node::Text(s) => text_box(f, s, size, false), + Node::Space(em) => MBox { w: em * size, ..Default::default() }, + Node::Group(list) => recursive_group_reference(f, list, size), + Node::Frac(a, b) => recursive_fraction_reference(f, a, b, size), + Node::Sqrt(idx, a) => recursive_sqrt_reference(f, idx, a, size), + Node::Big(op) => recursive_big_reference(f, op, size), + Node::Scripts { base, sup, sub } => recursive_scripts_reference(f, base, sup, sub, size), + Node::Fenced(open, body, close) => recursive_fenced_reference(f, open, body, close, size), + } +} + +/// Oracle for operator spacing, leading-sign treatment and punctuation gaps. +fn recursive_group_reference(f: &Fonts, list: &[Node], size: f64) -> MBox { + let mut parts = Vec::new(); + let mut prev: Option = None; + for (k, item) in list.iter().enumerate() { + let cl = match item { + Node::Sym(_, c) => Some(*c), + Node::Scripts { base, .. } => match &**base { + Node::Sym(_, c) => Some(*c), + _ => None, + }, + _ => None, + }; + // A leading + or − (or after another operator) is a sign, not a binary operator. + let cl = + if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { Some(Class::Plain) } else { cl }; + let gap = |c: Option| match c { + Some(Class::Bin) => size * 4.0 / 18.0, + Some(Class::Rel) => size * 5.0 / 18.0, + _ => 0.0, + }; + let mut before = gap(cl).max(gap(prev)); + if k == 0 { + before = 0.0; + } + if prev == Some(Class::Punct) { + before = before.max(size * 3.0 / 18.0); + } + parts.push((recursive_layout_reference(f, item, size), before)); + prev = cl; + } + hbox(parts) +} + +/// Oracle for fraction rule, child scale, math axis, gaps and bounds. +fn recursive_fraction_reference(f: &Fonts, a: &Node, b: &Node, size: f64) -> MBox { + let axis = size * 0.25; + let s2 = size * 0.85; + let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2)); + let w = na.w.max(nb.w) + size * 0.2; + let t = (size * 0.06).max(0.4); + let gap = size * 0.15; + let num_base = -(axis + t / 2.0 + gap + na.desc); + let den_base = -axis + t / 2.0 + gap + nb.asc; + let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; + out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); + let (wa, wb) = (na.w, nb.w); + out.els.extend(na.shifted((w - wa) / 2.0, num_base)); + out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); + out +} + +/// Oracle for radical geometry, body offset and optional root index. +fn recursive_sqrt_reference(f: &Fonts, idx: &Option>, a: &Node, size: f64) -> MBox { + let axis = size * 0.25; + let inner = recursive_layout_reference(f, a, size); + let t = (size * 0.05).max(0.4); + let gap = size * 0.12; + let top = inner.asc + gap + t; + let bottom = inner.desc; + let hook = size * 0.55; + let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; + // The radical: a short rise, the long stroke down, then up to the bar. + out.els.push(Elem::Line { + pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], + w: t, + }); + let iw = inner.w; + let _ = iw; + out.els.extend(inner.shifted(hook, 0.0)); + if let Some(i) = idx { + let ib = recursive_layout_reference(f, i, size * 0.55); + let (ia, iw2) = (ib.asc, ib.w); + out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); + out.asc = out.asc.max(axis + size * 0.1 + ia); + } + out +} + +/// Oracle for enlarged, math-axis-centred big operators. +fn recursive_big_reference(f: &Fonts, op: &str, size: f64) -> MBox { + let mut b = text_box(f, op, size * 1.5, false); + b.asc = size * 1.05; + b.desc = size * 0.35; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + // Centre the glyph on the math axis. + *y = size * 0.32; + } + } + b +} + +/// Oracle for ordinary sup/sub placement and big-operator limits above and below. +fn recursive_scripts_reference(f: &Fonts, base: &Node, sup: &Option>, sub: &Option>, size: f64) -> MBox { + let big = matches!(base, Node::Big(_)); + let b = recursive_layout_reference(f, base, size); + let s2 = size * 0.7; + let (bw, basc, bdesc) = (b.w, b.asc, b.desc); + let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; + if big { + // Limits above and below. + let mut w = bw; + if let Some(sp) = sup { + let sb = recursive_layout_reference(f, sp, s2); + let y = -basc - size * 0.1 - sb.desc; + w = w.max(sb.w); + let (sw, sa) = (sb.w, sb.asc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = recursive_layout_reference(f, sbn, s2); + let y = bdesc + size * 0.1 + sb.asc; + w = w.max(sb.w); + let (sw, sd) = (sb.w, sb.desc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.desc = out.desc.max(y + sd); + } + // Centre the operator over the widest limit. + let dx = (w - bw) / 2.0; + let els = std::mem::take(&mut out.els); + return MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; + } + let mut w = bw; + if let Some(sp) = sup { + let sb = recursive_layout_reference(f, sp, s2); + let y = -(size * 0.42).max(basc - sb.asc * 0.6); + w = w.max(bw + sb.w + size * 0.05); + let sa = sb.asc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = recursive_layout_reference(f, sbn, s2); + let y = (size * 0.2).max(bdesc * 0.8); + w = w.max(bw + sb.w + size * 0.05); + let sd = sb.desc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.desc = out.desc.max(y + sd); + } + out.w = w; + out +} + +/// Oracle for content-scaled, centred delimiters. +fn recursive_fenced_reference(f: &Fonts, open: &str, body: &Node, close: &str, size: f64) -> MBox { + let inner = recursive_layout_reference(f, body, size); + let h = (inner.asc + inner.desc).max(size); + let k = h / size; + let ds = size * k.max(1.0); + let mk = |s: &str| -> MBox { + if s.is_empty() { + return MBox::default(); + } + let mut b = text_box(f, s, ds, false); + // Scaled delimiters centred on the content. + let mid = (inner.desc - inner.asc) / 2.0; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = mid + ds * 0.25; + } + } + b.asc = inner.asc.max(size * 0.72); + b.desc = inner.desc.max(size * 0.22); + b + }; + let (l, r) = (mk(open), mk(close)); + hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) +} + +/// Asserts fraction/nested-root, big scripts, relation, symbol, fence and raw `\text` parse shapes. +#[test] +fn parses_the_common_constructs() { + let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}"); + let Node::Group(v) = &n else { panic!() }; + assert!(matches!(&v[0], Node::Frac(_, _))); + let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha"); + let Node::Group(v) = &n else { panic!() }; + assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_)))); + assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel))); + assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident))); + let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x"); + let Node::Group(v) = &n else { panic!() }; + assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")")); + assert!(v.contains(&Node::Text("if ".into()))); +} + +/// A command that merely starts with "right" (`\rightarrow`) must not close a `\left` fence. +#[test] +fn right_prefixed_commands_do_not_close_a_fence() { + let n = parse(r"\left( x \rightarrow y \right)"); + let Node::Group(v) = &n else { panic!() }; + assert_eq!(v.len(), 1, "{v:?}"); + let Node::Fenced(o, body, c) = &v[0] else { panic!("{v:?}") }; + assert!(o == "(" && c == ")", "{v:?}"); + let Node::Group(b) = &**body else { panic!("{v:?}") }; + assert_eq!(*b, vec![Node::Sym("x".into(), Class::Ident), Node::Sym("→".into(), Class::Rel), Node::Sym("y".into(), Class::Ident)]); + // An unknown `\right…` command stays inside the fence too. + let Node::Group(v) = &parse(r"\left[ \rightfoo \right]") else { panic!() }; + assert_eq!(v.len(), 1, "{v:?}"); + assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "[" && c == "]"), "{v:?}"); + // Real closers still close it. + for (src, close) in [(r"\left( x \right)", ")"), (r"\left( x \right.", ""), (r"\left( x \right)y", ")")] { + let Node::Group(v) = &parse(src) else { panic!() }; + assert!(matches!(&v[0], Node::Fenced(_, _, c) if c == close), "{src}: {v:?}"); + } +} + +#[test] +fn layout_sizes_and_svg() { + let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0); + let s = String::from_utf8(svg).unwrap(); + assert!(s.starts_with("E<") && s.contains(">2<")); + assert!(w > 30.0 && h > 10.0, "{w} {h}"); + // A fraction is taller than its numerator alone; a big operator with limits taller still. + let (_, (_, h1)) = to_svg("x", 12.0); + let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0); + let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0); + assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}"); + // The SVG parses and rasterises. + let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); + assert!(designcraft_images::svg_tree(&svg).is_some()); +} + +/// Hostile input must not overflow the stack: nesting past the cap stops descending instead +/// of recursing once per group (100k levels used to abort the process). +#[test] +fn an_over_deep_expression_is_bounded() { + for latex in [ + format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)), + format!("{}x", r"\frac".repeat(100_000)), + format!("{}x", r"\left(".repeat(100_000)), + format!("{}z{}", r"\sqrt{".repeat(1_024), "}".repeat(1_024)), + format!("{}z{}", "x^{".repeat(1_024), "}".repeat(1_024)), + ] { + let (svg, (w, h)) = to_svg(&latex, 12.0); + assert!(!svg.is_empty()); + assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); + } +} +/// Count actual tree shape without recursively comparing or formatting a deep AST. +fn shape(root: &Node) -> ([usize; 6], usize) { + let mut counts = [0; 6]; // groups, fractions, roots, fences, scripts, sentinel z + let mut deepest = 0; + let mut work = vec![(root, 1)]; + while let Some((node, depth)) = work.pop() { + deepest = deepest.max(depth); + match node { + Node::Group(nodes) => { + counts[0] += 1; + work.extend(nodes.iter().map(|node| (node, depth + 1))); + } + Node::Frac(a, b) => { + counts[1] += 1; + work.extend([(a.as_ref(), depth + 1), (b.as_ref(), depth + 1)]); + } + Node::Sqrt(index, body) => { + counts[2] += 1; + work.push((body, depth + 1)); + if let Some(index) = index { + work.push((index, depth + 1)); + } + } + Node::Fenced(_, body, _) => { + counts[3] += 1; + work.push((body, depth + 1)); + } + Node::Scripts { base, sup, sub } => { + counts[4] += 1; + work.push((base, depth + 1)); + if let Some(sup) = sup { + work.push((sup, depth + 1)); + } + if let Some(sub) = sub { + work.push((sub, depth + 1)); + } + } + Node::Sym(s, _) if s == "z" => counts[5] += 1, + _ => {} + } + } + (counts, deepest) +} + +/// With `MAX_NESTING == 256`, all supported 255-level constructs stay nested and still render their sentinel. +#[test] +fn the_full_256_budget_keeps_deep_valid_formulas_intact() { + assert_eq!(MAX_NESTING, 256, "preserve the existing expression capability"); + let groups = MAX_NESTING - 1; + let arguments = (MAX_NESTING - 1) / 2; + let index_groups = MAX_NESTING - 3; + let cases = [ + (format!("{}z{}", "{".repeat(groups), "}".repeat(groups)), [groups + 1, 0, 0, 0, 0, 1], groups + 2), + (format!("{}z{}", r"\left(".repeat(groups), r"\right)".repeat(groups)), [groups + 1, 0, 0, groups, 0, 1], groups * 2 + 2), + (format!("{}z{}", r"\frac{".repeat(arguments), "}{y}".repeat(arguments)), [arguments * 2 + 1, arguments, 0, 0, 0, 1], arguments * 2 + 2), + (format!("{}z{}", r"\sqrt{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, arguments, 0, 0, 1], arguments * 2 + 2), + (format!("{}z{}", "x^{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), + (format!("{}z{}", "x_{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), + (format!(r"\sqrt[{}z{}]{{y}}", "{".repeat(index_groups), "}".repeat(index_groups)), [index_groups + 3, 0, 1, 0, 0, 1], index_groups + 4), + ]; + for (latex, counts, depth) in cases { + assert_eq!(shape(&parse(&latex)), (counts, depth), "all constructs must remain nested, not flatten into siblings"); + let (svg, (w, h)) = to_svg(&latex, 12.0); + assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); + assert!(String::from_utf8(svg).unwrap().contains(">z<"), "the innermost content must render"); + } +} + +/// A capped list consumes nothing; capped root-index/body cursor behavior and the permissive first-`]` grammar remain unchanged. +#[test] +fn cap_and_index_returns_preserve_their_exact_cursor_semantics() { + let chars: Vec<_> = "x".chars().collect(); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!(p.parse(MAX_NESTING - 1), Node::Group(vec![Node::Sym("x".into(), Class::Ident)])); + assert_eq!(p.i, 1); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!(p.parse(MAX_NESTING), Node::Group(vec![])); + assert_eq!(p.i, 0, "a capped List does not consume its input"); + // Both the index subparse and radicand still have their entire old depth allowance. + let source = r"\sqrt[x]{y}z"; + assert_eq!( + parse_at(source, MAX_NESTING - 4), + Node::Group(vec![ + Node::Sqrt( + Some(Box::new(Node::Group(vec![Node::Sym("x".into(), Class::Ident)]))), + Box::new(Node::Group(vec![Node::Sym("y".into(), Class::Ident)])) + ), + Node::Sym("z".into(), Class::Ident), + ]) + ); + // At this starting depth the index and braced body are capped. The outer cursor is + // restored after ']', then the old non-consuming body cap leaves y/}/z as siblings. + let chars: Vec<_> = source.chars().collect(); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!( + p.parse(MAX_NESTING - 2), + Node::Group(vec![ + Node::Sqrt(Some(Box::new(Node::Group(vec![]))), Box::new(Node::Group(vec![]))), + Node::Sym("y".into(), Class::Ident), + Node::Sym("}".into(), Class::Plain), + Node::Sym("z".into(), Class::Ident), + ]) + ); + assert_eq!(p.i, chars.len()); + // Do not change the permissive first-']' index grammar into balanced bracket parsing. + assert_eq!( + parse(r"\sqrt[x[y]z]q"), + Node::Group(vec![ + Node::Sqrt( + Some(Box::new(Node::Group(vec![ + Node::Sym("x".into(), Class::Ident), + Node::Sym("[".into(), Class::Plain), + Node::Sym("y".into(), Class::Ident) + ]))), + Box::new(Node::Sym("z".into(), Class::Ident)) + ), + Node::Sym("]".into(), Class::Plain), + Node::Sym("q".into(), Class::Ident), + ]) + ); +} + +/// Repeated scripts replace slots; a fence `^` stays literal; incomplete fractions, escaped braces and unknown commands retain the prior grammar. +#[test] +fn heap_continuations_preserve_small_grammar_and_script_precedence() { + let sym = |s: &str| Node::Sym(s.into(), Class::Ident); + assert_eq!( + parse("x^{a}_{b}^{c}_{d}"), + Node::Group(vec![Node::Scripts { + base: Box::new(sym("x")), + sup: Some(Box::new(Node::Group(vec![sym("c")]))), + sub: Some(Box::new(Node::Group(vec![sym("d")]))), + }]) + ); + assert_eq!( + parse(r"\left( ^x \right)"), + Node::Group(vec![Node::Fenced("(".into(), Box::new(Node::Group(vec![Node::Sym("^".into(), Class::Plain), sym("x")])), ")".into())]) + ); + assert_eq!(parse(r"\frac{x"), Node::Group(vec![Node::Frac(Box::new(Node::Group(vec![sym("x")])), Box::new(Node::Group(vec![])))])); + assert_eq!( + parse(r"\text{a{b}}\{\}\_\unknown"), + Node::Group(vec![ + Node::Text("a{b}".into()), + Node::Sym("{".into(), Class::Punct), + Node::Sym("}".into(), Class::Punct), + Node::Sym("_".into(), Class::Plain), + Node::Text(r"\unknown".into()) + ]) + ); +} + +/// Iterative layout equals the frozen recursive oracle for representative formulas, including malformed inputs. +#[test] +fn heap_layout_preserves_the_existing_element_order_and_geometry() { + let db = FontDb::global(); + let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") }; + for latex in [ + "E = mc^2", + "x^{a}_{b}^{c}_{d}", + r"\frac{a^2+b}{\sqrt[3]{x}}", + r"\sum_{i=0}^{n} i^2", + r"\int_a^b x", + r"\left( x \rightarrow y \right) + \text{if } x", + r"\left. x \right]", + r"-x,+y;\quad z", + r"\sqrt[x[y]z]q", + r"\frac{x", + r"\left( ^x \right)", + ] { + let tree = parse(latex); + let actual = layout(&f, &tree, 12.0); + let expected = recursive_layout_reference(&f, &tree, 12.0); + assert_eq!((actual.w, actual.asc, actual.desc, actual.els), (expected.w, expected.asc, expected.desc, expected.els), "{latex}"); + } +} + +/// Build a deep five-way alternating AST for destructor stress tests. +fn mixed_chain(depth: usize) -> Node { + let mut node = Node::Sym("z".into(), Class::Ident); + for i in 0..depth { + node = match i % 5 { + 0 => Node::Group(vec![node]), + 1 => Node::Frac(Box::new(node), Box::new(Node::Text("d".into()))), + 2 => Node::Sqrt(Some(Box::new(Node::Text("3".into()))), Box::new(node)), + 3 => Node::Scripts { base: Box::new(node), sup: Some(Box::new(Node::Text("p".into()))), sub: Some(Box::new(Node::Text("q".into()))) }, + _ => Node::Fenced("(".into(), Box::new(node), ")".into()), + }; + } + node +} + +/// Drops 50k-node chains, replaces one script slot, and unwinds parser continuations while preserving the retained slot. +#[test] +fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() { + // Far beyond the parser budget: this directly proves the private destructor's traversal. + drop(mixed_chain(50_000)); + for up in [false, true] { + let old = Box::new(mixed_chain(50_000)); + let other = Box::new(Node::Text("other".into())); + let (sup, sub) = if up { (Some(old), Some(other)) } else { (Some(other), Some(old)) }; + let root = attach_script(Node::Scripts { base: Box::new(Node::Big("∑".into())), sup, sub }, Node::Text("new".into()), up); + let Node::Scripts { base, sup, sub } = &root else { panic!("script wrapper") }; + assert!(matches!(&**base, Node::Big(s) if s == "∑")); + let (replaced, retained) = if up { (sup, sub) } else { (sub, sup) }; + assert!(matches!(replaced.as_deref(), Some(Node::Text(s)) if s == "new")); + assert!(matches!(retained.as_deref(), Some(Node::Text(s)) if s == "other")); + } + let result = std::panic::catch_unwind(|| { + // Actual continuation variants own completed children while awaiting later input. + let _partial = [ + ParseWork::FractionDenominator { numerator: mixed_chain(25_000) }, + ParseWork::SqrtArgument { index: Some(Box::new(mixed_chain(25_000))) }, + ]; + panic!("exercise partial-parser-state unwinding"); + }); + assert!(result.is_err(), "the intentional panic must actually unwind the owned frames"); +} diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 7ec62d0731..eb6d998860 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -858,841 +858,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat } #[cfg(test)] -mod tests { - use super::*; - - fn dirty_session() -> Session { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - session.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - session - } - - #[test] - fn recovers_unsaved_documents() { - let dir = std::env::temp_dir().join(format!("dc-recovery-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.new", &json!({})).unwrap(); - // Only the edited document is written. - s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - assert_eq!(save(&s, &dir).unwrap(), 1); - assert_eq!(list(&dir).len(), 1); - // "Crash": a new session reopens it, unsaved, with the item. - let mut s2 = Session::new(); - let opened = open(&mut s2, &dir).unwrap(); - assert_eq!(opened.len(), 1); - let st = s2.doc().unwrap(); - assert!(st.is_dirty()); - assert_eq!(st.doc.spreads[0].items.len(), 1); - // The entry moved to the reopened document: a session that ends before its first - // recovery save (a restart, another crash) still leaves it to recover. - let entries = list(&dir); - assert_eq!(entries.iter().map(|e| e.0).collect::>(), [st.uid]); - let mut s3 = Session::new(); - assert_eq!(open(&mut s3, &dir).unwrap().len(), 1); - assert_eq!(s3.doc().unwrap().doc.spreads[0].items.len(), 1); - assert!(s3.doc().unwrap().is_dirty()); - assert_eq!(list(&dir).len(), 1, "recovering twice never duplicates an entry"); - discard(&dir, s3.doc().unwrap().uid); - // Saving a document drops its entry. - s.recovery_dir = Some(dir.clone()); - assert_eq!(save(&s, &dir).unwrap(), 1); - let path = dir.join("saved.designcraft"); - s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - assert!(list(&dir).is_empty()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_recovered_document_survives_a_second_crash_before_autosave() { - let dir = tempfile::tempdir().unwrap(); - let original = unsaved("edits preserved after a second crash"); - assert_eq!(save(&original, dir.path()).unwrap(), 1); - let mut reopened = Session::new(); - assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); - drop(reopened); - let mut recovered_again = Session::new(); - assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); - assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); - assert!(recovered_again.documents().iter().all(DocState::is_dirty)); - assert_eq!(list(dir.path()).len(), 1); - } - - #[test] - fn opening_recovery_again_does_not_duplicate_live_documents() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); - let mut reopened = Session::new(); - reopened.recovery_dir = Some(dir.path().to_path_buf()); - assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); - assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); - assert_eq!(texts(&reopened), ["one recovered document"]); - assert_eq!(list(dir.path()).len(), 1); - } - - /// Directory collisions fail even for privileged runners. Creating the blocker in the - /// publisher occurs after UID reservation, which would otherwise skip an occupied path. - fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result> { - let mut attempts = 0; - let opened = open_with_publisher(s, dir, |path, bytes| { - assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); - std::fs::create_dir(path).unwrap(); - let result = publish_recovery_file(path, bytes, false); - std::fs::remove_dir(path).unwrap(); - assert!(result.is_err(), "a directory must obstruct actual recovery publication"); - attempts += 1; - result - }); - assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); - opened - } - - #[test] - fn a_failed_recovery_rewrite_preserves_the_original() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); - let entries = list(dir.path()); - let original = files(dir.path(), entries[0].0).0; - let bytes = std::fs::read(&original).unwrap(); - let mut reopened = Session::new(); - let result = open_with_failed_republication(&mut reopened, dir.path()); - assert_eq!(result.unwrap().len(), 1); - assert_eq!(texts(&reopened), ["keep the original"]); - assert_eq!(std::fs::read(original).unwrap(), bytes); - assert_eq!(list(dir.path()).len(), 1); - // Its source uid differs from the reopened uid, but explicit Close still discards it. - assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); - reopened.close_document(0); - assert!(list(dir.path()).is_empty()); - } - - #[cfg(unix)] - #[test] - fn recovery_data_is_private_to_the_user() { - use std::os::unix::fs::PermissionsExt; - - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - let s = dirty_session(); - assert_eq!(save(&s, &dir).unwrap(), 1); - - assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); - let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect(); - assert_eq!(entries.len(), 2); - for entry in entries { - let entry = entry.unwrap(); - assert_eq!(entry.metadata().unwrap().permissions().mode() & 0o777, 0o600); - } - } - - #[test] - fn existing_recovery_directory_is_used() { - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - std::fs::create_dir(&dir).unwrap(); - - assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); - assert_eq!(list(&dir).len(), 1); - assert!(std::fs::symlink_metadata(&dir).unwrap().file_type().is_dir()); - } - - #[test] - fn non_directory_recovery_path_is_rejected() { - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("recovery"); - std::fs::write(&path, b"not a directory").unwrap(); - - assert!(save(&dirty_session(), &path).is_err()); - assert_eq!(std::fs::read(&path).unwrap(), b"not a directory"); - } - - #[cfg(unix)] - #[test] - fn existing_recovery_directory_permissions_are_preserved() { - use std::os::unix::fs::PermissionsExt; - - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - std::fs::create_dir(&dir).unwrap(); - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap(); - - assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); - assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o750); - } - - #[cfg(unix)] - #[test] - fn symlink_recovery_directory_is_rejected() { - use std::os::unix::fs::symlink; - - let root = tempfile::tempdir().unwrap(); - let destination = root.path().join("destination"); - let link = root.path().join("recovery"); - std::fs::create_dir(&destination).unwrap(); - symlink(&destination, &link).unwrap(); - - assert!(save(&dirty_session(), &link).is_err()); - assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); - std::fs::write(destination.join("7.designcraft"), b"unchanged").unwrap(); - std::fs::write(destination.join("7.json"), b"{}").unwrap(); - assert!(list(&link).is_empty()); - discard(&link, 7); - assert_eq!(std::fs::read(destination.join("7.designcraft")).unwrap(), b"unchanged"); - assert!(open(&mut Session::new(), &link).is_err()); - assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); - } - - #[test] - fn a_run_has_a_locked_folder_of_its_own() { - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - let own = run_dir(&dir).unwrap(); - assert_eq!(own.parent(), Some(dir.as_path())); - assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); - let lock = std::fs::File::open(own.join(LOCK)).unwrap(); - assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); - assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); - } - } - - fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { - let root = tempfile::tempdir().unwrap(); - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let live_uid = session.docs[0].uid; - let mut original = unsaved("retained old work"); - original.docs[0].uid = live_uid; - save(&original, root.path()).unwrap(); - session.recovery_dir = Some(root.path().to_path_buf()); - let recovered = open_with_failed_republication(&mut session, root.path()); - assert_eq!(recovered.unwrap().len(), 1); - assert_ne!(session.docs[1].uid, live_uid); - (session, root, live_uid) - } - - #[test] - fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { - let (mut session, root, live_uid) = failed_republication_session(); - let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); - let recovered_uid = session.docs[1].uid; - std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); - assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); - assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); - } - - #[test] - fn clean_autosave_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - assert!(!session.docs[0].is_dirty()); - assert!(session.docs[1].is_dirty()); - assert!(session.execute("file.recovery.save", &json!({})).is_err()); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn closing_a_coincident_document_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - session.execute("file.close", &json!({"index": 0})).unwrap(); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn saving_a_coincident_document_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn reverting_a_coincident_document_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - let saved = root.path().join("saved-other.designcraft"); - std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); - session.docs[0].path = Some(saved.to_string_lossy().into_owned()); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); - session.execute("file.revert", &json!({})).unwrap(); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { - let (mut session, root, live_uid) = failed_republication_session(); - session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); - session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); - let checkpoint = files(root.path(), live_uid).0; - let expected = std::fs::read(&checkpoint).unwrap(); - session.execute("file.close", &json!({"index": 1})).unwrap(); - assert_eq!( - std::fs::read(checkpoint).unwrap(), - expected, - "closing the saved recovered document must not delete another document's checkpoint" - ); - } - - #[test] - fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { - let root = tempfile::tempdir().unwrap(); - let mut session = unsaved("bounded checkpoints"); - for n in 0..MAX_RECOVERY_ENTRIES { - session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); - } - let excess = root.path().join("excess"); - assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); - assert!(!excess.exists(), "reject before creating or writing the next directory"); - let first_alias = root.path().join("0").join("."); - session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); - for n in 0..MAX_RECOVERY_ENTRIES { - assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); - } - assert!(session.recovery_dir.is_none()); - session.execute("file.close", &json!({})).unwrap(); - for n in 0..MAX_RECOVERY_ENTRIES { - assert!(list(&root.path().join(n.to_string())).is_empty()); - } - } - - fn restart_fixture() -> tempfile::TempDir { - let root = tempfile::tempdir().unwrap(); - let mut first = unsaved("restart first"); - first.docs[0].uid = 1; - save(&first, root.path()).unwrap(); - let mut second = unsaved("restart second"); - second.docs[0].uid = 2; - save(&second, root.path()).unwrap(); - root - } - - fn persisted_texts(dir: &Path) -> Vec { - let mut result = Vec::new(); - for (uid, _) in list(dir) { - let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); - result.extend(doc.stories.values().map(|story| story.text.clone())); - } - result.sort(); - result - } - - #[test] - #[ignore = "subprocess helper, executed by the restart recovery regression tests"] - fn restart_recovery_child() { - let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); - let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); - if mode == "occupied_destination" { - let owned_root = tempfile::tempdir().unwrap(); - let destination = run_dir(owned_root.path()).unwrap(); - let mut existing = unsaved("already in destination"); - existing.docs[0].uid = 3; - save(&existing, &destination).unwrap(); - let (existing_doc, existing_meta) = files(&destination, 3); - let doc_bytes = std::fs::read(&existing_doc).unwrap(); - let meta_bytes = std::fs::read(&existing_meta).unwrap(); - let mut session = Session::new(); - session.recovery_dir = Some(destination.clone()); - let opened = open(&mut session, &source).unwrap(); - assert_eq!(opened.len(), 2); - assert_eq!(texts(&session), ["restart first", "restart second"]); - assert!(session.docs.iter().all(|document| document.uid != 3)); - assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); - assert!(list(&source).is_empty()); - session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); - session.execute("file.close", &json!({"index": opened[0]})).unwrap(); - assert_eq!(list(&destination).len(), 1); - assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); - assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); - return; - } - let mut session = Session::new(); - if mode == "advance" || mode == "coincident_live" { - session.execute("file.new", &json!({})).unwrap(); - assert_eq!(session.docs[0].uid, 1); - } - let owned_root; - let destination = match mode.as_str() { - "cross_dir" => { - owned_root = tempfile::tempdir().unwrap(); - let own = run_dir(owned_root.path()).unwrap(); - session.recovery_dir = Some(own.clone()); - own - } - "alias" => { - let alias = source.join("."); - session.recovery_dir = Some(alias.clone()); - alias - } - "coincident_live" => { - session.recovery_dir = Some(source.clone()); - source.clone() - } - _ => source.clone(), - }; - let opened = open(&mut session, &source).unwrap(); - assert_eq!(opened.len(), 2); - assert_eq!(texts(&session), ["restart first", "restart second"]); - assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); - assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); - if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { - let was_disabled = session.recovery_dir.is_none(); - if mode == "cross_dir" { - assert!(list(&source).is_empty()); - } - // A copy does not consume recovery. A successful Save and a Close do. - session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(list(&destination).len(), 2); - session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(list(&destination).len(), 1); - if mode == "revert" { - session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); - session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); - assert_eq!(list(&destination).len(), 2); - session.execute("file.revert", &json!({})).unwrap(); - assert_eq!(list(&destination).len(), 1); - } - session.execute("file.close", &json!({"index": opened[0]})).unwrap(); - assert!(list(&destination).is_empty()); - assert!(list(&source).is_empty()); - assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); - } - } - - fn run_restart_case(mode: &str) { - let source = restart_fixture(); - if mode == "max_uid" { - let (doc, meta) = files(source.path(), 2); - let (max_doc, max_meta) = files(source.path(), u64::MAX); - std::fs::rename(doc, max_doc).unwrap(); - std::fs::rename(meta, max_meta).unwrap(); - } - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) - .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) - .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) - .output() - .unwrap(); - assert!( - output.status.success(), - "fresh child failed: {}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let child_stdout = String::from_utf8_lossy(&output.stdout); - assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); - } - - #[test] - fn restart_uid_collision_cannot_delete_the_new_snapshot() { - run_restart_case("fresh"); - } - #[test] - fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { - run_restart_case("advance"); - } - #[test] - fn cross_directory_recovery_uses_the_configured_destination() { - run_restart_case("cross_dir"); - } - #[test] - fn cross_directory_recovery_preserves_occupied_destination_entries() { - run_restart_case("occupied_destination"); - } - #[test] - fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { - run_restart_case("manual"); - } - #[test] - fn recovery_directory_aliases_do_not_delete_new_snapshots() { - run_restart_case("alias"); - } - #[test] - fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { - run_restart_case("max_uid"); - } - #[test] - fn a_coincident_live_uid_is_not_recovered_entry_provenance() { - run_restart_case("coincident_live"); - } - #[test] - fn manual_recovery_revert_removes_its_previous_checkpoint() { - run_restart_case("revert"); - } - - /// The recovery folder was cleared while the app runs: the next save makes the run's folder - /// again, and its lock with it. - #[cfg(unix)] - #[test] - fn a_removed_run_folder_is_made_again_with_its_lock() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_dir_all(&own).unwrap(); - assert_eq!(save(&dirty_session(), &own).unwrap(), 1); - let lock = std::fs::File::open(own.join(LOCK)).unwrap(); - assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); - } - - /// Replacing the name of a live run's lock must not authorize writes under the old handle. - #[cfg(unix)] - #[test] - fn save_rejects_a_run_whose_lock_was_replaced() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - let replacement = new_lock(&own).unwrap(); - replacement.try_lock().unwrap(); - assert!(save(&dirty_session(), &own).is_err()); - assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); - } - - #[cfg(windows)] - #[test] - fn an_exclusively_locked_windows_recovery_file_validates() { - let root = tempfile::tempdir().unwrap(); - let lock = new_lock(root.path()).unwrap(); - lock.try_lock().unwrap(); - validate_lock(root.path(), &lock).unwrap(); - let named = std::fs::File::open(root.path().join(LOCK)).unwrap(); - assert!(matches!(named.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "validation must retain the exclusive lock"); - } - - #[cfg(windows)] - #[test] - fn windows_recovery_rejects_a_replacement_with_the_same_marker() { - let root = tempfile::tempdir().unwrap(); - let lock = new_lock(root.path()).unwrap(); - let marker = std::fs::read(root.path().join(LOCK)).unwrap(); - lock.try_lock().unwrap(); - let moved = root.path().join("original-lock"); - std::fs::rename(root.path().join(LOCK), &moved).unwrap(); - std::fs::write(root.path().join(LOCK), marker).unwrap(); - assert!(validate_lock(root.path(), &lock).is_err(), "matching marker bytes do not make another file our lock"); - std::fs::remove_file(root.path().join(LOCK)).unwrap(); - assert!(validate_lock(root.path(), &lock).is_err(), "a missing lock must not validate"); - } - - #[cfg(windows)] - #[test] - fn windows_recovery_does_not_write_or_discard_a_replacement_runs_entries() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::rename(own.join(LOCK), own.join("original-lock")).unwrap(); - let replacement = new_lock(&own).unwrap(); - replacement.try_lock().unwrap(); - let session = dirty_session(); - let (doc, meta) = files(&own, session.docs[0].uid); - std::fs::write(&doc, b"another owner's document").unwrap(); - std::fs::write(&meta, b"{}").unwrap(); - assert!(save(&session, &own).is_err()); - discard(&own, session.docs[0].uid); - assert_eq!(std::fs::read(&doc).unwrap(), b"another owner's document"); - assert_eq!(std::fs::read(&meta).unwrap(), b"{}"); - } - - /// A blocked attempt to recreate a run's lock must stop the save before it writes documents. - #[cfg(unix)] - #[test] - fn save_rejects_a_run_whose_lock_cannot_be_recreated() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - std::fs::create_dir(own.join(LOCK)).unwrap(); - assert!(save(&dirty_session(), &own).is_err()); - assert!(list(&own).is_empty()); - } - - #[cfg(unix)] - #[test] - fn discarding_a_document_does_not_touch_a_replaced_runs_entries() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - let replacement = new_lock(&own).unwrap(); - replacement.try_lock().unwrap(); - let (doc, meta) = files(&own, 1); - std::fs::write(&doc, b"the replacement owner's document").unwrap(); - std::fs::write(&meta, b"{}").unwrap(); - discard(&own, 1); - assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document"); - assert!(meta.exists()); - } - - #[cfg(unix)] - #[test] - fn saving_a_run_rejects_a_symlinked_lock() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - let unrelated = root.path().join("unrelated"); - std::fs::write(&unrelated, b"unrelated file").unwrap(); - std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap(); - assert!(save(&dirty_session(), &own).is_err()); - assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file"); - } - - #[cfg(unix)] - #[test] - fn a_replaced_run_directory_is_not_claimed_or_overwritten() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - let session = dirty_session(); - let uid = session.docs[0].uid; - std::fs::remove_dir_all(&own).unwrap(); - std::fs::create_dir(&own).unwrap(); - let (doc, _) = files(&own, uid); - std::fs::write(&doc, b"another owner's document").unwrap(); - assert!(save(&session, &own).is_err()); - assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document"); - assert!(!own.join(LOCK).exists()); - } - - #[cfg(unix)] - #[test] - fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { - use std::os::unix::fs::symlink; - - let root = tempfile::tempdir().unwrap(); - let destination = root.path().join("destination"); - let link = root.path().join("recovery"); - std::fs::create_dir(&destination).unwrap(); - symlink(&destination, &link).unwrap(); - - assert!(run_dir(&link).is_err()); - assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); - } - - fn unsaved(text: &str) -> Session { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); - session - } - - fn texts(s: &Session) -> Vec { - let mut texts: Vec = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); - texts.sort(); - texts - } - - /// What a run that has ended leaves: its folder with its lock, free now, and the entry of - /// one unsaved document, numbered `uid` as that process numbered it. - fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { - let dir = root.join(name); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(LOCK), b"").unwrap(); - let mut s = unsaved(text); - s.docs[0].uid = uid; - assert_eq!(save(&s, &dir).unwrap(), 1); - dir - } - - /// Every process numbers its first document 1: two running apps both wrote `1.designcraft` - /// into the one recovery folder, and only the document written last was recovered. - #[test] - fn documents_of_two_apps_that_ended_are_both_recovered() { - let root = tempfile::tempdir().unwrap(); - let first = run_left(root.path(), "run-7-0", "first app", 1); - let second = run_left(root.path(), "run-8-0", "second app", 1); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 2); - assert_eq!(texts(&s), ["first app", "second app"]); - assert!(s.documents().iter().all(DocState::is_dirty)); - assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); - } - - /// An app that started reopened the unsaved documents of one still running, and removed - /// its entries. - #[test] - fn a_running_apps_recovery_data_is_left_alone() { - let root = tempfile::tempdir().unwrap(); - let running = run_left(root.path(), "run-7-0", "still open", 1); - let lock = std::fs::File::open(running.join(LOCK)).unwrap(); - lock.try_lock().unwrap(); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert!(open(&mut s, &own).unwrap().is_empty()); - assert_eq!(list(&running).len(), 1); - // Once it has ended, its document is recovered. - drop(lock); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(texts(&s), ["still open"]); - assert!(!running.exists()); - } - - /// A process id comes round again: the folder a run left under it is not this run's. - #[test] - fn a_folder_left_under_this_process_id_is_not_reused() { - let root = tempfile::tempdir().unwrap(); - let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); - let own = run_dir(root.path()).unwrap(); - assert_ne!(own, left); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(texts(&s), ["left behind"]); - } - - /// Entries written straight into the recovery folder, as before runs had folders. - #[test] - fn recovery_data_of_an_earlier_version_is_recovered() { - let root = tempfile::tempdir().unwrap(); - assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(texts(&s), ["earlier version"]); - assert!(list(root.path()).is_empty()); - } - - /// An entry that can't be opened stays on disk, in this run's folder now. - #[test] - fn an_unreadable_entry_of_a_run_that_ended_is_kept() { - let root = tempfile::tempdir().unwrap(); - let left = run_left(root.path(), "run-7-0", "readable", 1); - std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); - assert!(!left.exists()); - } - - /// Only a folder named as a run's, with a lock that is free, is a run that ended. - #[test] - fn what_is_no_run_that_ended_is_passed_over() { - let root = tempfile::tempdir().unwrap(); - std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); - // An app that is starting has made its folder and not yet its lock. - let starting = root.path().join("run-2-0"); - std::fs::create_dir(&starting).unwrap(); - let other = run_left(root.path(), "backup", "not a run", 1); - #[cfg(unix)] - let (_elsewhere, linked) = { - let elsewhere = tempfile::tempdir().unwrap(); - let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); - std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); - (elsewhere, linked) - }; - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert!(open(&mut s, &own).unwrap().is_empty()); - assert!(starting.exists()); - assert_eq!(list(&other).len(), 1); - #[cfg(unix)] - assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); - } - - /// The metadata goes with its document: a document taken over remembers its file. - #[test] - fn a_document_taken_over_remembers_its_file() { - let root = tempfile::tempdir().unwrap(); - let file = root.path().join("layout.designcraft"); - let recovery = root.path().join("recovery"); - let left = recovery.join("run-7-0"); - std::fs::create_dir_all(&left).unwrap(); - std::fs::write(left.join(LOCK), b"").unwrap(); - let mut a = unsaved("saved once"); - a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); - a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - assert_eq!(save(&a, &left).unwrap(), 1); - let own = run_dir(&recovery).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); - } - - /// The folder of a run that ended goes with what the app left in it. A file the app did not - /// write keeps the folder, and is not deleted. - #[test] - fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { - let root = tempfile::tempdir().unwrap(); - let emptied = run_left(root.path(), "run-7-0", "moved", 1); - std::fs::write(emptied.join("9.json"), b"{}").unwrap(); - std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); - let kept = run_left(root.path(), "run-8-0", "moved too", 1); - std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 2); - assert!(!emptied.exists()); - assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); - assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); - } - - /// A folder that is there is another app's or was left behind, lock or no lock: a run's own - /// is one it made itself. - #[test] - fn a_folder_that_is_there_is_not_made_this_runs() { - let root = tempfile::tempdir().unwrap(); - let left = root.path().join(format!("run-{}-0", std::process::id())); - std::fs::create_dir(&left).unwrap(); - std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); - let own = run_dir(root.path()).unwrap(); - assert_ne!(own, left); - assert!(!left.join(LOCK).exists()); - assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); - } - - /// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered - /// 7 too: document uids start at 1 in every process. - fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) { - let root = tempfile::tempdir().unwrap(); - let (doc, meta) = files(root.path(), 7); - std::fs::write(&doc, b"another run's document").unwrap(); - std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap(); - (root, doc, meta) - } - - #[test] - fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() { - let (root, doc, meta) = chosen_folder_with_another_runs_entry(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.docs[0].uid = 7; - assert!(!s.docs[0].is_dirty()); - s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); - s.execute("file.close", &json!({})).unwrap(); - assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); - assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); - } - - #[test] - fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() { - let (root, doc, meta) = chosen_folder_with_another_runs_entry(); - let mut s = unsaved("this run's work"); - s.docs[0].uid = 7; - assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); - assert!(save(&s, root.path()).is_err()); - assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); - assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); - } - - /// A chosen folder that is a running app's run folder is that app's. - #[test] - fn a_folder_another_running_app_holds_is_refused() { - let root = tempfile::tempdir().unwrap(); - let held = new_lock(root.path()).unwrap(); - held.try_lock().unwrap(); - assert!(save(&dirty_session(), root.path()).is_err()); - assert!(list(root.path()).is_empty()); - } -} +mod tests; #[cfg(test)] mod file_tests { diff --git a/crates/engine/src/recovery/tests.rs b/crates/engine/src/recovery/tests.rs new file mode 100644 index 0000000000..395e8385bf --- /dev/null +++ b/crates/engine/src/recovery/tests.rs @@ -0,0 +1,824 @@ +use super::*; + +fn dirty_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + session +} + +#[test] +fn recovers_unsaved_documents() { + let dir = std::env::temp_dir().join(format!("dc-recovery-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + // Only the edited document is written. + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&s, &dir).unwrap(), 1); + assert_eq!(list(&dir).len(), 1); + // "Crash": a new session reopens it, unsaved, with the item. + let mut s2 = Session::new(); + let opened = open(&mut s2, &dir).unwrap(); + assert_eq!(opened.len(), 1); + let st = s2.doc().unwrap(); + assert!(st.is_dirty()); + assert_eq!(st.doc.spreads[0].items.len(), 1); + // The entry moved to the reopened document: a session that ends before its first + // recovery save (a restart, another crash) still leaves it to recover. + let entries = list(&dir); + assert_eq!(entries.iter().map(|e| e.0).collect::>(), [st.uid]); + let mut s3 = Session::new(); + assert_eq!(open(&mut s3, &dir).unwrap().len(), 1); + assert_eq!(s3.doc().unwrap().doc.spreads[0].items.len(), 1); + assert!(s3.doc().unwrap().is_dirty()); + assert_eq!(list(&dir).len(), 1, "recovering twice never duplicates an entry"); + discard(&dir, s3.doc().unwrap().uid); + // Saving a document drops its entry. + s.recovery_dir = Some(dir.clone()); + assert_eq!(save(&s, &dir).unwrap(), 1); + let path = dir.join("saved.designcraft"); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(list(&dir).is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_recovered_document_survives_a_second_crash_before_autosave() { + let dir = tempfile::tempdir().unwrap(); + let original = unsaved("edits preserved after a second crash"); + assert_eq!(save(&original, dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + drop(reopened); + let mut recovered_again = Session::new(); + assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); + assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); + assert!(recovered_again.documents().iter().all(DocState::is_dirty)); + assert_eq!(list(dir.path()).len(), 1); +} + +#[test] +fn opening_recovery_again_does_not_duplicate_live_documents() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + reopened.recovery_dir = Some(dir.path().to_path_buf()); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + assert_eq!(texts(&reopened), ["one recovered document"]); + assert_eq!(list(dir.path()).len(), 1); +} + +/// Directory collisions fail even for privileged runners. Creating the blocker in the +/// publisher occurs after UID reservation, which would otherwise skip an occupied path. +fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result> { + let mut attempts = 0; + let opened = open_with_publisher(s, dir, |path, bytes| { + assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); + std::fs::create_dir(path).unwrap(); + let result = publish_recovery_file(path, bytes, false); + std::fs::remove_dir(path).unwrap(); + assert!(result.is_err(), "a directory must obstruct actual recovery publication"); + attempts += 1; + result + }); + assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); + opened +} + +#[test] +fn a_failed_recovery_rewrite_preserves_the_original() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); + let entries = list(dir.path()); + let original = files(dir.path(), entries[0].0).0; + let bytes = std::fs::read(&original).unwrap(); + let mut reopened = Session::new(); + let result = open_with_failed_republication(&mut reopened, dir.path()); + assert_eq!(result.unwrap().len(), 1); + assert_eq!(texts(&reopened), ["keep the original"]); + assert_eq!(std::fs::read(original).unwrap(), bytes); + assert_eq!(list(dir.path()).len(), 1); + // Its source uid differs from the reopened uid, but explicit Close still discards it. + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + reopened.close_document(0); + assert!(list(dir.path()).is_empty()); +} + +#[cfg(unix)] +#[test] +fn recovery_data_is_private_to_the_user() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let s = dirty_session(); + assert_eq!(save(&s, &dir).unwrap(), 1); + + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); + let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect(); + assert_eq!(entries.len(), 2); + for entry in entries { + let entry = entry.unwrap(); + assert_eq!(entry.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } +} + +#[test] +fn existing_recovery_directory_is_used() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + std::fs::create_dir(&dir).unwrap(); + + assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); + assert_eq!(list(&dir).len(), 1); + assert!(std::fs::symlink_metadata(&dir).unwrap().file_type().is_dir()); +} + +#[test] +fn non_directory_recovery_path_is_rejected() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("recovery"); + std::fs::write(&path, b"not a directory").unwrap(); + + assert!(save(&dirty_session(), &path).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"not a directory"); +} + +#[cfg(unix)] +#[test] +fn existing_recovery_directory_permissions_are_preserved() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap(); + + assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o750); +} + +#[cfg(unix)] +#[test] +fn symlink_recovery_directory_is_rejected() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(save(&dirty_session(), &link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); + std::fs::write(destination.join("7.designcraft"), b"unchanged").unwrap(); + std::fs::write(destination.join("7.json"), b"{}").unwrap(); + assert!(list(&link).is_empty()); + discard(&link, 7); + assert_eq!(std::fs::read(destination.join("7.designcraft")).unwrap(), b"unchanged"); + assert!(open(&mut Session::new(), &link).is_err()); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); +} + +#[test] +fn a_run_has_a_locked_folder_of_its_own() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let own = run_dir(&dir).unwrap(); + assert_eq!(own.parent(), Some(dir.as_path())); + assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); + } +} + +fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { + let root = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let live_uid = session.docs[0].uid; + let mut original = unsaved("retained old work"); + original.docs[0].uid = live_uid; + save(&original, root.path()).unwrap(); + session.recovery_dir = Some(root.path().to_path_buf()); + let recovered = open_with_failed_republication(&mut session, root.path()); + assert_eq!(recovered.unwrap().len(), 1); + assert_ne!(session.docs[1].uid, live_uid); + (session, root, live_uid) +} + +#[test] +fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { + let (mut session, root, live_uid) = failed_republication_session(); + let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); + let recovered_uid = session.docs[1].uid; + std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); +} + +#[test] +fn clean_autosave_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + assert!(!session.docs[0].is_dirty()); + assert!(session.docs[1].is_dirty()); + assert!(session.execute("file.recovery.save", &json!({})).is_err()); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn closing_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn saving_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn reverting_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + let saved = root.path().join("saved-other.designcraft"); + std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); + session.docs[0].path = Some(saved.to_string_lossy().into_owned()); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { + let (mut session, root, live_uid) = failed_republication_session(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + let checkpoint = files(root.path(), live_uid).0; + let expected = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 1})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), expected, "closing the saved recovered document must not delete another document's checkpoint"); +} + +#[test] +fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { + let root = tempfile::tempdir().unwrap(); + let mut session = unsaved("bounded checkpoints"); + for n in 0..MAX_RECOVERY_ENTRIES { + session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); + } + let excess = root.path().join("excess"); + assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); + assert!(!excess.exists(), "reject before creating or writing the next directory"); + let first_alias = root.path().join("0").join("."); + session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); + } + assert!(session.recovery_dir.is_none()); + session.execute("file.close", &json!({})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert!(list(&root.path().join(n.to_string())).is_empty()); + } +} + +fn restart_fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let mut first = unsaved("restart first"); + first.docs[0].uid = 1; + save(&first, root.path()).unwrap(); + let mut second = unsaved("restart second"); + second.docs[0].uid = 2; + save(&second, root.path()).unwrap(); + root +} + +fn persisted_texts(dir: &Path) -> Vec { + let mut result = Vec::new(); + for (uid, _) in list(dir) { + let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); + result.extend(doc.stories.values().map(|story| story.text.clone())); + } + result.sort(); + result +} + +#[test] +#[ignore = "subprocess helper, executed by the restart recovery regression tests"] +fn restart_recovery_child() { + let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); + let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); + if mode == "occupied_destination" { + let owned_root = tempfile::tempdir().unwrap(); + let destination = run_dir(owned_root.path()).unwrap(); + let mut existing = unsaved("already in destination"); + existing.docs[0].uid = 3; + save(&existing, &destination).unwrap(); + let (existing_doc, existing_meta) = files(&destination, 3); + let doc_bytes = std::fs::read(&existing_doc).unwrap(); + let meta_bytes = std::fs::read(&existing_meta).unwrap(); + let mut session = Session::new(); + session.recovery_dir = Some(destination.clone()); + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|document| document.uid != 3)); + assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); + assert!(list(&source).is_empty()); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert_eq!(list(&destination).len(), 1); + assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); + assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); + return; + } + let mut session = Session::new(); + if mode == "advance" || mode == "coincident_live" { + session.execute("file.new", &json!({})).unwrap(); + assert_eq!(session.docs[0].uid, 1); + } + let owned_root; + let destination = match mode.as_str() { + "cross_dir" => { + owned_root = tempfile::tempdir().unwrap(); + let own = run_dir(owned_root.path()).unwrap(); + session.recovery_dir = Some(own.clone()); + own + } + "alias" => { + let alias = source.join("."); + session.recovery_dir = Some(alias.clone()); + alias + } + "coincident_live" => { + session.recovery_dir = Some(source.clone()); + source.clone() + } + _ => source.clone(), + }; + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); + if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { + let was_disabled = session.recovery_dir.is_none(); + if mode == "cross_dir" { + assert!(list(&source).is_empty()); + } + // A copy does not consume recovery. A successful Save and a Close do. + session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 1); + if mode == "revert" { + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(list(&destination).len(), 1); + } + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert!(list(&destination).is_empty()); + assert!(list(&source).is_empty()); + assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); + } +} + +fn run_restart_case(mode: &str) { + let source = restart_fixture(); + if mode == "max_uid" { + let (doc, meta) = files(source.path(), 2); + let (max_doc, max_meta) = files(source.path(), u64::MAX); + std::fs::rename(doc, max_doc).unwrap(); + std::fs::rename(meta, max_meta).unwrap(); + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) + .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) + .output() + .unwrap(); + assert!(output.status.success(), "fresh child failed: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + let child_stdout = String::from_utf8_lossy(&output.stdout); + assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); +} + +#[test] +fn restart_uid_collision_cannot_delete_the_new_snapshot() { + run_restart_case("fresh"); +} +#[test] +fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { + run_restart_case("advance"); +} +#[test] +fn cross_directory_recovery_uses_the_configured_destination() { + run_restart_case("cross_dir"); +} +#[test] +fn cross_directory_recovery_preserves_occupied_destination_entries() { + run_restart_case("occupied_destination"); +} +#[test] +fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { + run_restart_case("manual"); +} +#[test] +fn recovery_directory_aliases_do_not_delete_new_snapshots() { + run_restart_case("alias"); +} +#[test] +fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { + run_restart_case("max_uid"); +} +#[test] +fn a_coincident_live_uid_is_not_recovered_entry_provenance() { + run_restart_case("coincident_live"); +} +#[test] +fn manual_recovery_revert_removes_its_previous_checkpoint() { + run_restart_case("revert"); +} + +/// The recovery folder was cleared while the app runs: the next save makes the run's folder +/// again, and its lock with it. +#[cfg(unix)] +#[test] +fn a_removed_run_folder_is_made_again_with_its_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_dir_all(&own).unwrap(); + assert_eq!(save(&dirty_session(), &own).unwrap(), 1); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); +} + +/// Replacing the name of a live run's lock must not authorize writes under the old handle. +#[cfg(unix)] +#[test] +fn save_rejects_a_run_whose_lock_was_replaced() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); +} + +#[cfg(windows)] +#[test] +fn an_exclusively_locked_windows_recovery_file_validates() { + let root = tempfile::tempdir().unwrap(); + let lock = new_lock(root.path()).unwrap(); + lock.try_lock().unwrap(); + validate_lock(root.path(), &lock).unwrap(); + let named = std::fs::File::open(root.path().join(LOCK)).unwrap(); + assert!(matches!(named.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "validation must retain the exclusive lock"); +} + +#[cfg(windows)] +#[test] +fn windows_recovery_rejects_a_replacement_with_the_same_marker() { + let root = tempfile::tempdir().unwrap(); + let lock = new_lock(root.path()).unwrap(); + let marker = std::fs::read(root.path().join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let moved = root.path().join("original-lock"); + std::fs::rename(root.path().join(LOCK), &moved).unwrap(); + std::fs::write(root.path().join(LOCK), marker).unwrap(); + assert!(validate_lock(root.path(), &lock).is_err(), "matching marker bytes do not make another file our lock"); + std::fs::remove_file(root.path().join(LOCK)).unwrap(); + assert!(validate_lock(root.path(), &lock).is_err(), "a missing lock must not validate"); +} + +#[cfg(windows)] +#[test] +fn windows_recovery_does_not_write_or_discard_a_replacement_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::rename(own.join(LOCK), own.join("original-lock")).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let session = dirty_session(); + let (doc, meta) = files(&own, session.docs[0].uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + assert!(save(&session, &own).is_err()); + discard(&own, session.docs[0].uid); + assert_eq!(std::fs::read(&doc).unwrap(), b"another owner's document"); + assert_eq!(std::fs::read(&meta).unwrap(), b"{}"); +} + +/// A blocked attempt to recreate a run's lock must stop the save before it writes documents. +#[cfg(unix)] +#[test] +fn save_rejects_a_run_whose_lock_cannot_be_recreated() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + std::fs::create_dir(own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty()); +} + +#[cfg(unix)] +#[test] +fn discarding_a_document_does_not_touch_a_replaced_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let (doc, meta) = files(&own, 1); + std::fs::write(&doc, b"the replacement owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + discard(&own, 1); + assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document"); + assert!(meta.exists()); +} + +#[cfg(unix)] +#[test] +fn saving_a_run_rejects_a_symlinked_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let unrelated = root.path().join("unrelated"); + std::fs::write(&unrelated, b"unrelated file").unwrap(); + std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file"); +} + +#[cfg(unix)] +#[test] +fn a_replaced_run_directory_is_not_claimed_or_overwritten() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + let session = dirty_session(); + let uid = session.docs[0].uid; + std::fs::remove_dir_all(&own).unwrap(); + std::fs::create_dir(&own).unwrap(); + let (doc, _) = files(&own, uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + assert!(save(&session, &own).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document"); + assert!(!own.join(LOCK).exists()); +} + +#[cfg(unix)] +#[test] +fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(run_dir(&link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); +} + +fn unsaved(text: &str) -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); + session +} + +fn texts(s: &Session) -> Vec { + let mut texts: Vec = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); + texts.sort(); + texts +} + +/// What a run that has ended leaves: its folder with its lock, free now, and the entry of +/// one unsaved document, numbered `uid` as that process numbered it. +fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(LOCK), b"").unwrap(); + let mut s = unsaved(text); + s.docs[0].uid = uid; + assert_eq!(save(&s, &dir).unwrap(), 1); + dir +} + +/// Every process numbers its first document 1: two running apps both wrote `1.designcraft` +/// into the one recovery folder, and only the document written last was recovered. +#[test] +fn documents_of_two_apps_that_ended_are_both_recovered() { + let root = tempfile::tempdir().unwrap(); + let first = run_left(root.path(), "run-7-0", "first app", 1); + let second = run_left(root.path(), "run-8-0", "second app", 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert_eq!(texts(&s), ["first app", "second app"]); + assert!(s.documents().iter().all(DocState::is_dirty)); + assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); +} + +/// An app that started reopened the unsaved documents of one still running, and removed +/// its entries. +#[test] +fn a_running_apps_recovery_data_is_left_alone() { + let root = tempfile::tempdir().unwrap(); + let running = run_left(root.path(), "run-7-0", "still open", 1); + let lock = std::fs::File::open(running.join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert_eq!(list(&running).len(), 1); + // Once it has ended, its document is recovered. + drop(lock); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["still open"]); + assert!(!running.exists()); +} + +/// A process id comes round again: the folder a run left under it is not this run's. +#[test] +fn a_folder_left_under_this_process_id_is_not_reused() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["left behind"]); +} + +/// Entries written straight into the recovery folder, as before runs had folders. +#[test] +fn recovery_data_of_an_earlier_version_is_recovered() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["earlier version"]); + assert!(list(root.path()).is_empty()); +} + +/// An entry that can't be opened stays on disk, in this run's folder now. +#[test] +fn an_unreadable_entry_of_a_run_that_ended_is_kept() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), "run-7-0", "readable", 1); + std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); + assert!(!left.exists()); +} + +/// Only a folder named as a run's, with a lock that is free, is a run that ended. +#[test] +fn what_is_no_run_that_ended_is_passed_over() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); + // An app that is starting has made its folder and not yet its lock. + let starting = root.path().join("run-2-0"); + std::fs::create_dir(&starting).unwrap(); + let other = run_left(root.path(), "backup", "not a run", 1); + #[cfg(unix)] + let (_elsewhere, linked) = { + let elsewhere = tempfile::tempdir().unwrap(); + let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); + std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); + (elsewhere, linked) + }; + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert!(starting.exists()); + assert_eq!(list(&other).len(), 1); + #[cfg(unix)] + assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); +} + +/// The metadata goes with its document: a document taken over remembers its file. +#[test] +fn a_document_taken_over_remembers_its_file() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("layout.designcraft"); + let recovery = root.path().join("recovery"); + let left = recovery.join("run-7-0"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::write(left.join(LOCK), b"").unwrap(); + let mut a = unsaved("saved once"); + a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&a, &left).unwrap(), 1); + let own = run_dir(&recovery).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); +} + +/// The folder of a run that ended goes with what the app left in it. A file the app did not +/// write keeps the folder, and is not deleted. +#[test] +fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { + let root = tempfile::tempdir().unwrap(); + let emptied = run_left(root.path(), "run-7-0", "moved", 1); + std::fs::write(emptied.join("9.json"), b"{}").unwrap(); + std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); + let kept = run_left(root.path(), "run-8-0", "moved too", 1); + std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert!(!emptied.exists()); + assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); + assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); +} + +/// A folder that is there is another app's or was left behind, lock or no lock: a run's own +/// is one it made itself. +#[test] +fn a_folder_that_is_there_is_not_made_this_runs() { + let root = tempfile::tempdir().unwrap(); + let left = root.path().join(format!("run-{}-0", std::process::id())); + std::fs::create_dir(&left).unwrap(); + std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + assert!(!left.join(LOCK).exists()); + assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); +} + +/// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered +/// 7 too: document uids start at 1 in every process. +fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let (doc, meta) = files(root.path(), 7); + std::fs::write(&doc, b"another run's document").unwrap(); + std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap(); + (root, doc, meta) +} + +#[test] +fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.docs[0].uid = 7; + assert!(!s.docs[0].is_dirty()); + s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); +} + +#[test] +fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = unsaved("this run's work"); + s.docs[0].uid = 7; + assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert!(save(&s, root.path()).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); +} + +/// A chosen folder that is a running app's run folder is that app's. +#[test] +fn a_folder_another_running_app_holds_is_refused() { + let root = tempfile::tempdir().unwrap(); + let held = new_lock(root.path()).unwrap(); + held.try_lock().unwrap(); + assert!(save(&dirty_session(), root.path()).is_err()); + assert!(list(root.path()).is_empty()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1792485df..6347b605f0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2,6 +2,15 @@ use serde_json::json; use super::*; +mod autosize; +mod file_guard; +mod idml; +mod ime; +mod linked_paths; +mod params; +mod roots; +mod table; + fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 2})).unwrap(); diff --git a/crates/engine/src/tests_autosize.rs b/crates/engine/src/tests/autosize.rs similarity index 100% rename from crates/engine/src/tests_autosize.rs rename to crates/engine/src/tests/autosize.rs diff --git a/crates/engine/src/tests_file_guard.rs b/crates/engine/src/tests/file_guard.rs similarity index 91% rename from crates/engine/src/tests_file_guard.rs rename to crates/engine/src/tests/file_guard.rs index 9f2af3c323..d27a13b797 100644 --- a/crates/engine/src/tests_file_guard.rs +++ b/crates/engine/src/tests/file_guard.rs @@ -31,7 +31,7 @@ const ALLOWED: &[(&str, &str)] = &[ "`OutputRoot` checks the folder against the write root before it creates anything and then only writes inside it. Its reads are of the font files that draw the document's text (installed fonts and the `Document Fonts` folder), never of a path a command names", ), ( - "fonts/src/fontdb.rs", + "fonts/src/fontdb/system.rs", "the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either", ), ( @@ -68,8 +68,8 @@ fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { let mut i = 0; while i < lines.len() { let attr = lines[i].trim(); - let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); - if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { + let module = lines.get(i + 1).map(|l| without_visibility(l.trim_start())); + if attr.starts_with("#[cfg(") && attr.replace("not(test)", "").contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { let header = lines[i + 1]; i += 2; if header.trim_end().ends_with('{') { @@ -88,6 +88,14 @@ fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { code } +/// `item` without a leading `pub`, `pub(crate)`, `pub(super)` or `pub(in …)`: a test module +/// that sibling modules share is still a test module. +fn without_visibility(item: &str) -> &str { + let Some(rest) = item.strip_prefix("pub") else { return item }; + let rest = if rest.starts_with('(') { rest.find(')').map_or(rest, |close| &rest[close + 1..]) } else { rest }; + if rest.starts_with(char::is_whitespace) { rest.trim_start() } else { item } +} + /// `line` without its `//` comment (a `//` inside a string or char is rare enough in this code /// to leave alone: it only hides the rest of that line from the scan). fn without_comment(line: &str) -> &str { @@ -131,7 +139,7 @@ fn imports_fs(text: &str) -> Vec { for (n, line) in code_outside_tests(text) { let line = without_comment(line); if statement.is_empty() { - if !line.trim_start().starts_with("use ") && !line.trim_start().starts_with("pub use ") { + if !without_visibility(line.trim_start()).starts_with("use ") { continue; } start = n; @@ -247,4 +255,6 @@ fn the_scan_sees_every_spelling() { } assert!(imports_fs("use std::io::Read;\nuse std::path::Path;").is_empty()); assert!(imports_fs("#[cfg(test)]\nmod tests {\n use std::fs;\n}\n").is_empty(), "test modules are skipped"); + assert!(imports_fs("#[cfg(test)]\npub(super) mod shared {\n use std::fs;\n}\n").is_empty(), "shared test modules are skipped"); + assert!(!imports_fs("#[cfg(not(test))]\npub(crate) mod real {\n use std::fs;\n}\n").is_empty(), "cfg(not(test)) modules are scanned"); } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests/idml.rs similarity index 100% rename from crates/engine/src/tests_idml.rs rename to crates/engine/src/tests/idml.rs diff --git a/crates/engine/src/tests_ime.rs b/crates/engine/src/tests/ime.rs similarity index 100% rename from crates/engine/src/tests_ime.rs rename to crates/engine/src/tests/ime.rs diff --git a/crates/engine/src/tests_linked_paths.rs b/crates/engine/src/tests/linked_paths.rs similarity index 100% rename from crates/engine/src/tests_linked_paths.rs rename to crates/engine/src/tests/linked_paths.rs diff --git a/crates/engine/src/tests_params.rs b/crates/engine/src/tests/params.rs similarity index 100% rename from crates/engine/src/tests_params.rs rename to crates/engine/src/tests/params.rs diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests/roots.rs similarity index 100% rename from crates/engine/src/tests_roots.rs rename to crates/engine/src/tests/roots.rs diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests/table.rs similarity index 100% rename from crates/engine/src/tests_table.rs rename to crates/engine/src/tests/table.rs diff --git a/crates/epub/src/chapters.rs b/crates/epub/src/chapters.rs new file mode 100644 index 0000000000..ede96f3904 --- /dev/null +++ b/crates/epub/src/chapters.rs @@ -0,0 +1,154 @@ +use super::css::{export_mapping, para_classes}; +use super::html::Block; +use super::markup::xml_text; +use super::*; + +/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never +/// when it's off, before each paragraph of the one style it names, or before each paragraph of +/// a style whose Export Tagging has Split Document on. +pub(crate) fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool { + match split { + EpubSplit::Off => false, + EpubSplit::Style(name) => name == style, + EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split), + } +} + +/// Where the EPUB body splits into chapter files (see [`starts_chapter`]). +pub(crate) const CHAPTER_MARK: &str = ""; + +/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed +/// and the one a split goes through reopened in the next; a part with nothing but the start of a +/// section (a story that opens with its `h1`) moves that start into the next part. +pub(crate) fn split_chapters(body: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut carry: Option = None; + for piece in body.split(CHAPTER_MARK) { + let mut part = carry.take().unwrap_or_default(); + part.push_str(piece); + if let Some(open) = part.rfind("")) { + let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1); + if part[tag_end..].trim().is_empty() { + carry = Some(part[open..].to_string()); + part.truncate(open); + } else { + part.push_str("\n"); + carry = Some("
\n".to_string()); + } + } + if !part.trim().is_empty() { + out.push(part); + } + } + if let Some(rest) = carry.filter(|c| !c.trim().is_empty()) + && let Some(last) = out.last_mut() + { + // Only an opening tag can be left over; close it where it stands. + last.push_str(&rest); + last.push_str("
\n"); + } + if out.is_empty() { + out.push(String::new()); + } + out +} + +/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another +/// chapter file pointed at that file. +pub(crate) fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { + const HREF: &str = "href=\"#"; + let mut out = String::with_capacity(chapter.len()); + let mut rest = chapter; + while let Some(i) = rest.find(HREF) { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i + HREF.len()..).unwrap_or_default(); + let id = tail.split('"').next().unwrap_or_default(); + match files.get(id) { + Some(file) if file != own => { + let _ = write!(out, "href=\"{file}#"); + } + _ => out.push_str(HREF), + } + rest = tail; + } + out.push_str(rest); + out +} + +/// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. +pub(crate) fn chapter_file(k: usize) -> String { + if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } +} + +/// The level of a heading element (`h1` → 1 … `h6` → 6). +pub(crate) fn heading_level(tag: &str) -> Option { + tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) +} + +/// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in +/// reading order, or, in a document without headings, each story's first paragraph (level 1). +pub(crate) fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { + let pc = para_classes(&doc.styles); + let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::().trim().to_string() }; + let mut headings = Vec::new(); + let mut firsts = Vec::new(); + for (k, b) in order.iter().enumerate() { + let Block::Story(sid) = b else { continue }; + // The endnote story isn't written (the endnotes section stands in for it). + if doc.endnote_story == Some(*sid) { + continue; + } + let Some(st) = doc.story(*sid) else { continue }; + let first = label(st.text.split('\n').next().unwrap_or("")); + if !first.is_empty() { + firsts.push((format!("s{k}"), first, 1)); + } + for (pi, r) in st.para_ranges().iter().enumerate() { + let Some(pf) = st.paras.get(pi) else { continue }; + let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc); + let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue }; + let text = label(st.text.get(r.clone()).unwrap_or("")); + if !text.is_empty() { + headings.push((format!("s{k}-p{pi}"), text, level)); + } + } + } + if headings.is_empty() { firsts } else { headings } +} + +/// The table of contents as nested `li`/`ol` items (the content of the navigation's outer +/// `ol`): a deeper level goes into the previous item, skipped levels count as one. +pub(crate) fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + for (target, label, level) in entries { + let file = files.get(target).map_or("content.xhtml", String::as_str); + let item = format!("
  • {}", xml_text(label)); + match open.last().copied() { + None => open.push(*level), + Some(top) if *level > top => { + out.push_str("
      "); + open.push(*level); + } + Some(_) => { + // Close the levels whose parent is as deep as this entry or deeper. + while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { + open.pop(); + out.push_str("
    "); + } + out.push_str("
  • "); + if let Some(t) = open.last_mut() { + *t = (*t).min(*level); + } + } + } + out.push_str(&item); + } + for i in (0..open.len()).rev() { + out.push_str(""); + if i > 0 { + out.push_str(""); + } + } + out +} diff --git a/crates/epub/src/css.rs b/crates/epub/src/css.rs new file mode 100644 index 0000000000..526b10a40a --- /dev/null +++ b/crates/epub/src/css.rs @@ -0,0 +1,226 @@ +use super::fonts::{EmbeddedFont, css_weight_style, font_file, generic_family}; +use super::markup::{css_string, slug, valid_xml_char}; +use super::*; + +/// Class names for `names`, in order: [`slug`]s made unique with a `-2`, `-3`… suffix, so styles +/// whose names slug alike ("Body Text" / "Body-Text", or non-ASCII names) keep their own rules. +fn class_names<'a>(names: impl Iterator) -> BTreeMap<&'a str, String> { + let mut out = BTreeMap::new(); + let mut used = std::collections::BTreeSet::new(); + for name in names { + let base = slug(name); + let mut class = base.clone(); + let mut n = 1; + while !used.insert(class.clone()) { + n += 1; + class = format!("{base}-{n}"); + } + out.insert(name, class); + } + out +} + +pub(crate) fn para_classes(st: &Styles) -> BTreeMap<&str, String> { + class_names(st.paragraph.iter().map(|s| s.name.as_str())) +} + +pub(crate) fn char_classes(st: &Styles) -> BTreeMap<&str, String> { + class_names(st.character.iter().map(|s| s.name.as_str())) +} + +fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { + let c = doc.resolve_color(swatch, tint)?; + let [r, g, b, _] = c.to_rgba8(1.0); + Some(format!("#{r:02x}{g:02x}{b:02x}")) +} + +pub(crate) fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap, out: &mut String) { + let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics)); + // The face's own weight, so an embedded Semibold or Light face is the one chosen. + let (weight, style) = css_weight_style(&c.font_style); + let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); + let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); + if c.tracking != 0.0 { + let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); + } + match c.capitalization { + designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), + designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { + out.push_str("font-variant: small-caps; ") + } + _ => {} + } + // One declaration: a later `text-decoration` would replace the earlier one. + match (c.underline, c.strikethrough) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} + } + if let Some(h) = hex(doc, &c.fill, c.fill_tint) { + let _ = write!(out, "color: {h}; "); + } +} + +fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { + let align = match p.align { + Align::Left | Align::TowardsSpine => "left", + Align::Center => "center", + Align::Right | Align::AwayFromSpine => "right", + _ => "justify", + }; + let _ = write!(out, "text-align: {align}; "); + let em = |v: f64| v / base_size; + let _ = write!( + out, + "margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ", + em(p.space_before), + em(p.right_indent), + em(p.space_after), + em(p.left_indent), + em(p.first_line_indent), + line_px + ); + out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " }); +} + +/// The stylesheet for all paragraph and character styles. +pub fn stylesheet(doc: &Document) -> String { + stylesheet_with(doc, &BTreeMap::new(), &[]) +} + +/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the +/// embedded fonts (packaged as [`font_file`] names them). +pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap, fonts: &[EmbeddedFont]) -> String { + let st: &Styles = &doc.styles; + let mut faces = String::new(); + for (k, f) in fonts.iter().enumerate() { + let (path, _) = font_file(k, f); + let (weight, style) = css_weight_style(&f.style); + let _ = writeln!( + faces, + "@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}", + css_string(&f.family) + ); + } + let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); + let base = basic.size.max(1.0); + let mut css = faces; + css.push_str( + "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", + ); + let (pc, cc) = (para_classes(st), char_classes(st)); + for ps in &st.paragraph { + if ps.name == designcraft_doc::NO_PARA_STYLE { + continue; + } + let (pp, cp) = st.resolve_para_style(&ps.name); + let lead = match cp.leading { + designcraft_doc::Leading::Auto => pp.auto_leading, + designcraft_doc::Leading::Points(v) => v / cp.size.max(1.0), + }; + let mut rule = String::new(); + para_css(&pp, base, lead, &mut rule); + char_css(doc, &cp, base, generics, &mut rule); + let (tag, class) = export_mapping(st, &ps.name, false, &pc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } + } + for cs in &st.character { + if cs.name == story::NO_CHAR_STYLE { + continue; + } + let mut rule = String::new(); + let c = &cs.chars; + if let Some(f) = &c.font_style { + let f = f.to_lowercase(); + if f.contains("bold") { + rule.push_str("font-weight: bold; "); + } + if f.contains("italic") { + rule.push_str("font-style: italic; "); + } + } + if let Some(fill) = &c.fill + && let Some(h) = hex(doc, fill, c.fill_tint.unwrap_or(1.0)) + { + let _ = write!(rule, "color: {h}; "); + } + if let Some(sz) = c.size { + let _ = write!(rule, "font-size: {:.3}em; ", sz / base); + } + let (tag, class) = export_mapping(st, &cs.name, true, &cc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } + } + css +} + +pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { + let mut s = String::new(); + if let Some(f) = &o.font_style { + let f = f.to_lowercase(); + if f.contains("bold") { + s.push_str("font-weight: bold; "); + } + if f.contains("italic") { + s.push_str("font-style: italic; "); + } + } + if let Some(fill) = &o.fill + && let Some(h) = hex(doc, fill, o.fill_tint.unwrap_or(1.0)) + { + let _ = write!(s, "color: {h}; "); + } + if o.underline == Some(true) { + s.push_str("text-decoration: underline; "); + } + if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { + s.push_str("text-transform: uppercase; "); + } + s.trim_end().to_string() +} + +/// Elements Export Tagging may choose (paragraph or character level). +fn is_tag(t: &str, character: bool) -> bool { + if character { + matches!(t, "span" | "em" | "strong" | "i" | "b" | "code" | "sup" | "sub" | "small" | "mark" | "cite" | "q") + } else { + matches!(t, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "pre" | "li" | "figcaption" | "aside" | "div") + } +} + +/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, +/// collision-free class when no custom class was supplied, and the existing tag fallback. +pub(crate) fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { + let et = st.export_tag(name, character); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); + // Match the characters xml_attr actually emits, before escaping either output context. + let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + (tag, class) +} + +/// Each class token must match on the same element. Escape CSS identifier punctuation and +/// leading digits without changing the class written into XHTML. +fn class_selector(tag: &str, class: &str) -> Option { + let mut tokens = class.split_ascii_whitespace().peekable(); + // A nonempty but whitespace-only custom class has no token to select. Never emit a bare + // element selector, which would apply this style to unrelated paragraphs or character runs. + tokens.peek()?; + let mut selector = tag.to_string(); + for token in tokens { + selector.push('.'); + for (i, c) in token.chars().enumerate() { + let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); + if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { + selector.push(c); + } else { + let _ = write!(selector, "\\{:x} ", c as u32); + } + } + } + Some(selector) +} diff --git a/crates/epub/src/fonts.rs b/crates/epub/src/fonts.rs new file mode 100644 index 0000000000..ce601d0705 --- /dev/null +++ b/crates/epub/src/fonts.rs @@ -0,0 +1,83 @@ +use super::*; + +/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). +#[derive(Clone, Debug)] +pub struct EmbeddedFont { + pub family: String, + pub style: String, + pub data: Vec, +} + +/// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an +/// export can embed. +pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> { + let st: &Styles = &doc.styles; + let mut out: Vec<(String, String)> = Vec::new(); + for ps in &st.paragraph { + if ps.name == designcraft_doc::NO_PARA_STYLE { + continue; + } + let (_, cp) = st.resolve_para_style(&ps.name); + let f = (cp.font_family.clone(), cp.font_style.clone()); + if !f.0.is_empty() && !out.contains(&f) { + out.push(f); + } + } + out +} + +/// The CSS generic family for `family`: the known one, else a guess from the name +/// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/ +/// `Verdana` → sans-serif), else serif. +pub(crate) fn generic_family<'a>(family: &str, known: &'a BTreeMap) -> &'a str { + const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]; + if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) { + return g; + } + let f = family.to_lowercase(); + let has = |words: &[&str]| words.iter().any(|w| f.contains(w)); + if has(&["mono", "courier", " code", "consol"]) { + "monospace" + } else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) { + "sans-serif" + } else { + "serif" + } +} + +/// CSS weight and style of a style name (`Semibold Italic` → 600, italic). +pub(crate) fn css_weight_style(style: &str) -> (u16, &'static str) { + let s = style.to_lowercase().replace([' ', '-'], ""); + let weight = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("bold", 700), + ("black", 900), + ("heavy", 900), + ] + .iter() + .find(|(n, _)| s.contains(n)) + .map_or(400, |(_, w)| *w); + (weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" }) +} + +/// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`). +pub(crate) fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { + let (ext, mime) = match f.data.get(..4) { + Some(b"OTTO") => ("otf", "font/otf"), + Some(b"wOFF") => ("woff", "font/woff"), + Some(b"wOF2") => ("woff2", "font/woff2"), + _ => ("ttf", "font/ttf"), + }; + let name: String = + format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); + (format!("fonts/{k}-{name}.{ext}"), mime) +} diff --git a/crates/epub/src/html.rs b/crates/epub/src/html.rs new file mode 100644 index 0000000000..ce9d7176cf --- /dev/null +++ b/crates/epub/src/html.rs @@ -0,0 +1,212 @@ +use super::chapters::toc_entries; +use super::css::stylesheet_with; +use super::markup::{language_tag, xml_attr, xml_text}; +use super::story_writer::StoryWriter; +use super::*; + +/// Reading order of document content: (page, y, x) of each story's first frame and of graphics. +#[derive(Debug, Clone, PartialEq)] +pub enum Block { + Story(StoryId), + Image(ItemId), +} + +pub fn reading_order(doc: &Document) -> Vec { + // Articles, when there are any to export, decide the order (and what's included). + if doc.articles.iter().any(|a| a.export) { + let mut out = Vec::new(); + let mut seen = Vec::new(); + for a in doc.articles.iter().filter(|a| a.export) { + for id in &a.items { + let Some(top) = doc.item(*id) else { continue }; + let mut push = |it: &Item| match &it.content { + Content::Text(tf) if !seen.contains(&tf.story) => { + seen.push(tf.story); + out.push(Block::Story(tf.story)); + } + Content::Graphic(_) => out.push(Block::Image(it.id)), + _ => {} + }; + top.walk(&mut |i| push(i)); + } + } + return out; + } + let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); + let mut seen_stories = Vec::new(); + for (si, sp) in doc.spreads.iter().enumerate() { + let first = doc.first_page_of_spread(si); + // Preserve traversal order while carrying each group's transform to spread space. + // The iterator stack grows with nesting depth, without repeated document lookups. + let mut stack = vec![(sp.items.iter(), Affine::IDENTITY)]; + while let Some((items, parent)) = stack.last_mut() { + let Some(it) = items.next() else { + stack.pop(); + continue; + }; + let xf = *parent * it.xf; + let children = it.children(); + if !children.is_empty() { + stack.push((children.iter(), xf)); + } + if it.hidden { + continue; + } + let block = match &it.content { + Content::Text(tf) => { + let Some(story) = doc.story(tf.story) else { continue }; + // A story is placed where its first frame is. + if story.frames.first() != Some(&it.id) || seen_stories.contains(&tf.story) || story.text.trim().is_empty() { + continue; + } + seen_stories.push(tf.story); + Block::Story(tf.story) + } + Content::Graphic(_) => Block::Image(it.id), + _ => continue, + }; + // Transform the path once: rotating a local axis-aligned box again would + // enlarge it and give a different top-left from the actual spread geometry. + let b = it.path.transformed(xf).bounds().unwrap_or(Rect::ZERO); + let page = first + sp.page_at_x(b.center().x).unwrap_or(0); + let key = (page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64); + keyed.push((key, block)); + } + } + let _ = SpreadRef::Doc(0); + keyed.sort_by_key(|(k, _)| *k); + keyed.into_iter().map(|(_, b)| b).collect() +} + +/// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name +/// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same +/// name apart. +pub(crate) fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String { + let stem = a.name.rsplit(['/', '\\']).next().unwrap_or(""); + let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s); + let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name: String = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(40).collect(); + if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) } +} + +fn ext(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + "video/mp4" => "mp4", + "video/quicktime" => "mov", + "video/webm" => "webm", + "video/ogg" => "ogv", + "audio/mpeg" => "mp3", + "audio/mp4" => "m4a", + "audio/wav" => "wav", + "audio/ogg" => "ogg", + "audio/aac" => "aac", + _ => "png", + } +} + +/// The document content as HTML, with the files it names. +pub(crate) struct Body<'a> { + pub(crate) html: String, + /// Images, video and sound by package name: (media type, bytes). + pub(crate) images: BTreeMap, + /// Table of contents: (element id, label, level). + pub(crate) toc: Vec<(String, String, u8)>, + /// Asset names of the images written without alt text. + no_alt: Vec, +} + +/// The content in reading order as HTML sections and figures; `img_src` names each image +/// (a package path, or a data URI for single-file HTML). +pub(crate) fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { + let order = reading_order(doc); + let mut writer = StoryWriter::new(doc, epub, Some(img_src)); + if let Some(split) = split { + writer.split = split.clone(); + } + let mut body = String::new(); + let mut toc = toc_entries(doc, &order); + for (k, b) in order.iter().enumerate() { + match b { + // The endnote frame's text is generated from the endnotes, which end the body as a + // linked section instead. + Block::Story(sid) if doc.endnote_story == Some(*sid) => {} + Block::Story(sid) => { + let anchor = format!("s{k}"); + let _ = write!(body, "
    \n{}
    \n", writer.story(*sid, epub.then_some(anchor.as_str()))); + } + Block::Image(iid) => { + let Some(it) = doc.item(*iid) else { continue }; + let Some((markup, media)) = writer.graphic(it) else { continue }; + if media { + let _ = writeln!(body, "
    {markup}
    "); + continue; + } + // Object Export Options: alignment and a page break before. + let eo = &it.export_options; + let mut css = String::new(); + if matches!(eo.align.as_str(), "left" | "center" | "right") { + css.push_str(&format!("text-align:{};", eo.align)); + } + if eo.page_break_before { + css.push_str("page-break-before:always;break-before:page;"); + } + let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) }; + let _ = writeln!(body, "{markup}
    "); + } + } + } + let endnotes = writer.endnotes_section(); + if !endnotes.is_empty() { + let heading = &doc.endnote_options.heading; + if !heading.trim().is_empty() { + toc.push(("endnotes".into(), heading.clone(), 1)); + } + body.push_str(&endnotes); + } + Body { html: body, images: writer.images, toc, no_alt: writer.no_alt } +} + +/// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as +/// decorative (`alt=""`), which an export can report. +pub fn images_without_alt(doc: &Document) -> Vec { + body_html(doc, true, None, &|name, _, _| name.to_string()).no_alt +} + +/// Options for [`export_html`]. +#[derive(Clone, Debug, Default)] +pub struct HtmlOptions { + pub title: Option, + pub language: Option, + /// Generic CSS family per font family (see [`EpubOptions::generic_families`]). + pub generic_families: BTreeMap, +} + +/// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the +/// document's stories and graphics in reading order. +pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { + let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); + let language = opts.language.as_deref().unwrap_or("en"); + let lang = language_tag(language); + let body = body_html(doc, false, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; + format!( + "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", + xml_text(&title), + stylesheet_with(doc, &opts.generic_families, &[]) + ) +} + +pub(crate) fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() { + out.push(if i <= c.len() { T[(n >> shift & 63) as usize] as char } else { '=' }); + } + } + out +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index c3150966fb..c102a54fe9 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -19,6 +19,31 @@ use designcraft_doc::{ ParaProps, SpreadRef, Story, StoryId, Styles, Table, story, }; +mod chapters; +mod css; +mod fonts; +mod html; +mod markup; +mod package; +mod story_writer; + +#[cfg(test)] +use chapters::{CHAPTER_MARK, link_across_chapters, nav_list, split_chapters}; +#[cfg(test)] +use css::char_css; +pub use css::stylesheet; +pub use fonts::{EmbeddedFont, stylesheet_fonts}; +#[cfg(test)] +use fonts::{css_weight_style, generic_family}; +#[cfg(test)] +use html::base64; +pub use html::{Block, HtmlOptions, export_html, images_without_alt, reading_order}; +pub use markup::{EpubError, slug}; +#[cfg(test)] +use package::iso8601; +pub use package::{FixedPage, contents_title, export_epub, export_fixed_epub}; +pub use story_writer::story_html; + #[derive(Clone, Debug)] pub struct EpubOptions { pub title: Option, @@ -42,242 +67,6 @@ pub struct EpubOptions { pub split: Option, } -/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). -#[derive(Clone, Debug)] -pub struct EmbeddedFont { - pub family: String, - pub style: String, - pub data: Vec, -} - -/// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an -/// export can embed. -pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> { - let st: &Styles = &doc.styles; - let mut out: Vec<(String, String)> = Vec::new(); - for ps in &st.paragraph { - if ps.name == designcraft_doc::NO_PARA_STYLE { - continue; - } - let (_, cp) = st.resolve_para_style(&ps.name); - let f = (cp.font_family.clone(), cp.font_style.clone()); - if !f.0.is_empty() && !out.contains(&f) { - out.push(f); - } - } - out -} - -/// The CSS generic family for `family`: the known one, else a guess from the name -/// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/ -/// `Verdana` → sans-serif), else serif. -fn generic_family<'a>(family: &str, known: &'a BTreeMap) -> &'a str { - const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]; - if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) { - return g; - } - let f = family.to_lowercase(); - let has = |words: &[&str]| words.iter().any(|w| f.contains(w)); - if has(&["mono", "courier", " code", "consol"]) { - "monospace" - } else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) { - "sans-serif" - } else { - "serif" - } -} - -/// CSS weight and style of a style name (`Semibold Italic` → 600, italic). -fn css_weight_style(style: &str) -> (u16, &'static str) { - let s = style.to_lowercase().replace([' ', '-'], ""); - let weight = [ - ("thin", 100), - ("extralight", 200), - ("ultralight", 200), - ("light", 300), - ("medium", 500), - ("semibold", 600), - ("demibold", 600), - ("extrabold", 800), - ("ultrabold", 800), - ("bold", 700), - ("black", 900), - ("heavy", 900), - ] - .iter() - .find(|(n, _)| s.contains(n)) - .map_or(400, |(_, w)| *w); - (weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" }) -} - -/// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`). -fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { - let (ext, mime) = match f.data.get(..4) { - Some(b"OTTO") => ("otf", "font/otf"), - Some(b"wOFF") => ("woff", "font/woff"), - Some(b"wOF2") => ("woff2", "font/woff2"), - _ => ("ttf", "font/ttf"), - }; - let name: String = - format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); - let name = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); - (format!("fonts/{k}-{name}.{ext}"), mime) -} - -/// 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 (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. -fn modified(doc: &Document, opts: &EpubOptions) -> String { - let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); - t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601) -} - -/// The book's identifier: the given one, else a `urn:uuid` derived from the title and the -/// document's creation time, so a book keeps its identifier from export to export and two books -/// with the same title don't share one. -fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String { - if let Some(id) = opts.identifier.clone().filter(|i| !i.trim().is_empty()) { - return id; - } - // Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID. - let seed = format!("{title}\n{}", doc.created); - let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)); - let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x84222325_cbf29ce4)); - let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000; - let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000; - format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff) -} - -/// Package name and media type of a cover image: JPEG by its signature, else PNG. -fn cover_file(data: &[u8]) -> (&'static str, &'static str) { - if data.starts_with(&[0xFF, 0xD8, 0xFF]) { ("images/cover.jpg", "image/jpeg") } else { ("images/cover.png", "image/png") } -} - -/// The table of contents' title in `language` (a BCP 47 tag or a language name's code), English -/// for languages without one here. -pub fn contents_title(language: &str) -> &'static str { - let code = language.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); - match code.as_str() { - "nl" => "Inhoud", - "de" => "Inhalt", - "fr" => "Table des matières", - "es" | "pt" => "Índice", - "it" => "Indice", - "ja" => "目次", - "zh" => "目录", - _ => "Contents", - } -} - -/// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, -/// read-aloud and accessibility). -#[derive(Clone, Debug)] -pub struct FixedPage { - pub png: Vec, - pub width: f64, - pub height: f64, - pub text: String, -} - -/// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. -pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); - let lang = language_tag(&opts.language); - let id = identifier(doc, opts, &title); - let modified = modified(doc, opts); - let mut manifest = String::from("\n"); - let mut spine = String::new(); - let mut files: Vec<(String, Vec)> = Vec::new(); - let mut nav_items = String::new(); - for (i, p) in pages.iter().enumerate() { - let n = i + 1; - let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32); - let xhtml = format!( - "\n\n\n{} — {n}\n\n\"\"/
    {}
    \n\n", - xml_text(&title), - story_markup(&p.text).replace('\n', "
    ") - ); - let _ = writeln!( - manifest, - "\n", - if n == 1 { " properties=\"cover-image\"" } else { "" } - ); - let _ = writeln!(spine, ""); - let _ = write!(nav_items, "
  • {n}
  • "); - files.push((format!("OEBPS/page{n}.xhtml"), xhtml.into_bytes())); - files.push((format!("OEBPS/pages/p{n}.png"), p.png.clone())); - } - let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32)); - let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title) - ); - let rendition = format!( - "\npre-paginated\nauto\n" - ); - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), 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 `` (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!("{}", xml_text(a))).unwrap_or_default(); - format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\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) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); - let container = "\n\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())] - { - z.start_file(name, deflate).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - 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)?; - } - Ok(buf.into_inner()) -} - impl Default for EpubOptions { fn default() -> Self { EpubOptions { @@ -295,9 +84,6 @@ impl Default for EpubOptions { } } -#[derive(Debug)] -pub struct EpubError(pub String); - impl std::fmt::Display for EpubError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) @@ -305,2133 +91,5 @@ impl std::fmt::Display for EpubError { } impl std::error::Error for EpubError {} -fn valid_xml_char(c: char) -> bool { - matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') -} - -fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - '\'' if attr => o.push_str("'"), - '\u{E000}'..='\u{E1FF}' => {} - '\u{2028}' if story_breaks => o.push_str("
    "), - '\u{AD}' => o.push_str("­"), - c if valid_xml_char(c) => o.push(c), - _ => {} - } - } - o -} - -fn xml_text(s: &str) -> String { - escape_markup(s, false, false) -} - -fn xml_attr(s: &str) -> String { - escape_markup(s, true, false) -} - -/// Story content permits the document's discretionary line-separator marker to become markup. -/// Other contexts always serialize it as ordinary text. -fn story_markup(s: &str) -> String { - escape_markup(s, false, true) -} - -/// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes. -fn language_tag(s: &str) -> &str { - if s.len() > 63 { - return "und"; - } - let mut parts = s.split('-'); - let Some(first) = parts.next() else { return "und" }; - if !(2..=8).contains(&first.len()) || !first.bytes().all(|b| b.is_ascii_alphabetic()) { - return "und"; - } - if parts.any(|p| p.is_empty() || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric())) { - return "und"; - } - s -} - -/// Return a MIME type that is safe to place in an attribute and a data URL. Parameters are omitted -/// because exported assets are always represented as their original binary bytes. -fn safe_mime(mime: &str) -> &str { - fn token(s: &str) -> bool { - !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'+' | b'-')) - } - - let mime = mime.split_once(';').map_or(mime, |(essence, _)| essence).trim(); - if mime.len() <= 127 - && let Some((kind, subtype)) = mime.split_once('/') - && !subtype.contains('/') - && token(kind) - && token(subtype) - { - mime - } else { - "image/png" - } -} - -/// Quote arbitrary document metadata as one CSS string token. Escaping `<` is important because -/// HTML parses the contents of a `style` element before CSS parsing sees it. -fn css_string(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - if matches!(c, '"' | '\\' | '<' | '>' | '&') || c.is_control() { - let _ = write!(out, "\\{:x} ", c as u32); - } else { - out.push(c); - } - } - out -} - -/// CSS class name for a style name. -pub fn slug(name: &str) -> String { - let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); - let s = s.trim_matches('-').to_string(); - if s.is_empty() { - "basic".into() - } else if s.starts_with(|c: char| c.is_ascii_digit()) { - format!("s-{s}") - } else { - s - } -} - -/// Class names for `names`, in order: [`slug`]s made unique with a `-2`, `-3`… suffix, so styles -/// whose names slug alike ("Body Text" / "Body-Text", or non-ASCII names) keep their own rules. -fn class_names<'a>(names: impl Iterator) -> BTreeMap<&'a str, String> { - let mut out = BTreeMap::new(); - let mut used = std::collections::BTreeSet::new(); - for name in names { - let base = slug(name); - let mut class = base.clone(); - let mut n = 1; - while !used.insert(class.clone()) { - n += 1; - class = format!("{base}-{n}"); - } - out.insert(name, class); - } - out -} - -fn para_classes(st: &Styles) -> BTreeMap<&str, String> { - class_names(st.paragraph.iter().map(|s| s.name.as_str())) -} - -fn char_classes(st: &Styles) -> BTreeMap<&str, String> { - class_names(st.character.iter().map(|s| s.name.as_str())) -} - -fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { - let c = doc.resolve_color(swatch, tint)?; - let [r, g, b, _] = c.to_rgba8(1.0); - Some(format!("#{r:02x}{g:02x}{b:02x}")) -} - -fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap, out: &mut String) { - let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics)); - // The face's own weight, so an embedded Semibold or Light face is the one chosen. - let (weight, style) = css_weight_style(&c.font_style); - let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); - let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); - if c.tracking != 0.0 { - let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); - } - match c.capitalization { - designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), - designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { - out.push_str("font-variant: small-caps; ") - } - _ => {} - } - // One declaration: a later `text-decoration` would replace the earlier one. - match (c.underline, c.strikethrough) { - (true, true) => out.push_str("text-decoration: underline line-through; "), - (true, false) => out.push_str("text-decoration: underline; "), - (false, true) => out.push_str("text-decoration: line-through; "), - (false, false) => {} - } - if let Some(h) = hex(doc, &c.fill, c.fill_tint) { - let _ = write!(out, "color: {h}; "); - } -} - -fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { - let align = match p.align { - Align::Left | Align::TowardsSpine => "left", - Align::Center => "center", - Align::Right | Align::AwayFromSpine => "right", - _ => "justify", - }; - let _ = write!(out, "text-align: {align}; "); - let em = |v: f64| v / base_size; - let _ = write!( - out, - "margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ", - em(p.space_before), - em(p.right_indent), - em(p.space_after), - em(p.left_indent), - em(p.first_line_indent), - line_px - ); - out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " }); -} - -/// The stylesheet for all paragraph and character styles. -pub fn stylesheet(doc: &Document) -> String { - stylesheet_with(doc, &BTreeMap::new(), &[]) -} - -/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the -/// embedded fonts (packaged as [`font_file`] names them). -fn stylesheet_with(doc: &Document, generics: &BTreeMap, fonts: &[EmbeddedFont]) -> String { - let st: &Styles = &doc.styles; - let mut faces = String::new(); - for (k, f) in fonts.iter().enumerate() { - let (path, _) = font_file(k, f); - let (weight, style) = css_weight_style(&f.style); - let _ = writeln!( - faces, - "@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}", - css_string(&f.family) - ); - } - let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); - let base = basic.size.max(1.0); - let mut css = faces; - css.push_str( - "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", - ); - let (pc, cc) = (para_classes(st), char_classes(st)); - for ps in &st.paragraph { - if ps.name == designcraft_doc::NO_PARA_STYLE { - continue; - } - let (pp, cp) = st.resolve_para_style(&ps.name); - let lead = match cp.leading { - designcraft_doc::Leading::Auto => pp.auto_leading, - designcraft_doc::Leading::Points(v) => v / cp.size.max(1.0), - }; - let mut rule = String::new(); - para_css(&pp, base, lead, &mut rule); - char_css(doc, &cp, base, generics, &mut rule); - let (tag, class) = export_mapping(st, &ps.name, false, &pc); - if let Some(selector) = class_selector(tag, &class) { - let _ = writeln!(css, "{selector} {{ {rule}}}"); - } - } - for cs in &st.character { - if cs.name == story::NO_CHAR_STYLE { - continue; - } - let mut rule = String::new(); - let c = &cs.chars; - if let Some(f) = &c.font_style { - let f = f.to_lowercase(); - if f.contains("bold") { - rule.push_str("font-weight: bold; "); - } - if f.contains("italic") { - rule.push_str("font-style: italic; "); - } - } - if let Some(fill) = &c.fill - && let Some(h) = hex(doc, fill, c.fill_tint.unwrap_or(1.0)) - { - let _ = write!(rule, "color: {h}; "); - } - if let Some(sz) = c.size { - let _ = write!(rule, "font-size: {:.3}em; ", sz / base); - } - let (tag, class) = export_mapping(st, &cs.name, true, &cc); - if let Some(selector) = class_selector(tag, &class) { - let _ = writeln!(css, "{selector} {{ {rule}}}"); - } - } - css -} - -fn override_css(doc: &Document, o: &CharAttrs) -> String { - let mut s = String::new(); - if let Some(f) = &o.font_style { - let f = f.to_lowercase(); - if f.contains("bold") { - s.push_str("font-weight: bold; "); - } - if f.contains("italic") { - s.push_str("font-style: italic; "); - } - } - if let Some(fill) = &o.fill - && let Some(h) = hex(doc, fill, o.fill_tint.unwrap_or(1.0)) - { - let _ = write!(s, "color: {h}; "); - } - if o.underline == Some(true) { - s.push_str("text-decoration: underline; "); - } - if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { - s.push_str("text-transform: uppercase; "); - } - s.trim_end().to_string() -} - -/// Elements Export Tagging may choose (paragraph or character level). -fn is_tag(t: &str, character: bool) -> bool { - if character { - matches!(t, "span" | "em" | "strong" | "i" | "b" | "code" | "sup" | "sub" | "small" | "mark" | "cite" | "q") - } else { - matches!(t, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "pre" | "li" | "figcaption" | "aside" | "div") - } -} - -/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, -/// collision-free class when no custom class was supplied, and the existing tag fallback. -fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { - let et = st.export_tag(name, character); - let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); - let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); - // Match the characters xml_attr actually emits, before escaping either output context. - let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); - (tag, class) -} - -/// Each class token must match on the same element. Escape CSS identifier punctuation and -/// leading digits without changing the class written into XHTML. -fn class_selector(tag: &str, class: &str) -> Option { - let mut tokens = class.split_ascii_whitespace().peekable(); - // A nonempty but whitespace-only custom class has no token to select. Never emit a bare - // element selector, which would apply this style to unrelated paragraphs or character runs. - tokens.peek()?; - let mut selector = tag.to_string(); - for token in tokens { - selector.push('.'); - for (i, c) in token.chars().enumerate() { - let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); - if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { - selector.push(c); - } else { - let _ = write!(selector, "\\{:x} ", c as u32); - } - } - } - Some(selector) -} - -/// Closes the open list, if any. -fn close_list(list: &mut Option<&str>, out: &mut String) { - if let Some(l) = list.take() { - let _ = writeln!(out, "", l.split(' ').next().unwrap_or(l)); - } -} - -/// Closes the open preformatted block and list, if any. -fn close_blocks(list: &mut Option<&str>, pre: &mut Option<(String, bool)>, out: &mut String) { - if pre.take().is_some() { - out.push_str("\n"); - } - close_list(list, out); -} - -/// Nesting limit for stories inside stories (table cells, footnotes, tables in cells), so a deeply -/// nested or cyclic document can't recurse without bound. -const MAX_DEPTH: usize = 8; - -/// Writes stories with their tables and notes, numbering notes across the stories of one export. -struct StoryWriter<'a, 'f> { - doc: &'a Document, - pc: BTreeMap<&'a str, String>, - cc: BTreeMap<&'a str, String>, - /// EPUB structural semantics (`epub:type`); plain HTML doesn't declare the namespace. - epub: bool, - /// Notes written so far, for unique element ids. - footnote_ids: u32, - endnote_ids: u32, - /// Number shown on the next footnote and endnote of the current story. - next_footnote: u32, - next_endnote: u32, - /// Endnotes met so far: (number, list item markup). - endnotes: Vec<(u32, String)>, - /// Names each packaged file (a package path, or a data URI for single-file HTML). Without - /// it, anchored objects are left out. - img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>, - /// Images, video and sound met so far, by package name. - images: BTreeMap, - /// Asset names of the images written without alt text (decorative). - no_alt: Vec, - /// Prefix of the `id`s the current story's headings get (`{prefix}-p{paragraph index}`, the - /// targets of the table of contents); only top-level paragraphs get one. - heading_ids: Option, - /// Where EPUB content files start (see [`starts_chapter`]). - split: EpubSplit, -} - -impl<'a, 'f> StoryWriter<'a, 'f> { - fn new(doc: &'a Document, epub: bool, img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>) -> Self { - StoryWriter { - doc, - pc: para_classes(&doc.styles), - cc: char_classes(&doc.styles), - epub, - footnote_ids: 0, - endnote_ids: 0, - next_footnote: 1, - next_endnote: 1, - endnotes: Vec::new(), - img_src, - images: BTreeMap::new(), - no_alt: Vec::new(), - heading_ids: None, - split: doc.settings.epub_split.clone(), - } - } - - fn epub_type(&self, t: &str) -> String { - if self.epub { format!(" epub:type=\"{t}\"") } else { String::new() } - } - - /// One document story: paragraphs, tables, and each footnote after the paragraph that refers - /// to it. - fn story(&mut self, sid: StoryId, heading_ids: Option<&str>) -> String { - let Some(st) = self.doc.story(sid) else { return String::new() }; - self.heading_ids = heading_ids.map(str::to_string); - self.next_footnote = self.doc.footnote_start(sid); - self.next_endnote = self.doc.endnote_start(sid); - let mut out = String::new(); - self.paragraphs(st, 0, "", &mut out); - out - } - - /// `st`'s paragraphs. `lead` (a note's number) starts the first paragraph. - /// - /// Consecutive `li` paragraphs share one list (`ol` for a numbered style, `ul` for a bulleted - /// one, an unmarked `ul` when the style has no list type of its own, e.g. typed bullets), and - /// consecutive `pre` paragraphs of one style one block, their lines separated by newlines. - fn paragraphs(&mut self, st: &Story, depth: usize, lead: &str, out: &mut String) { - let doc = self.doc; - let mut lead = lead; - let mut list: Option<&str> = None; - let mut pre: Option<(String, bool)> = None; - for (pi, (r, pf)) in st.para_ranges().into_iter().zip(&st.paras).enumerate() { - let Some(text) = st.text.get(r.clone()) else { continue }; - let table = pf.table.and_then(|id| st.tables.get(&id)).filter(|_| text.contains(story::TABLE_ANCHOR)); - let only_table = table.is_some() && text.chars().all(|c| c == story::TABLE_ANCHOR || c.is_whitespace()); - if !only_table || !lead.is_empty() { - // Export Tagging: the style's element and class. - let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc); - let props = doc.styles.resolve_para(pf).0; - let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft; - // Chapter files start only at top-level paragraphs, after any open list or - // preformatted block is closed, so each file stays balanced. - if depth == 0 && self.heading_ids.is_some() && starts_chapter(&self.split, &doc.styles, &pf.style) { - close_blocks(&mut list, &mut pre, out); - out.push_str(CHAPTER_MARK); - } - let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl)); - if !continues_pre && pre.take().is_some() { - out.push_str("\n"); - } - let list_tag = (tag == "li").then_some(match props.list_type { - designcraft_doc::ListType::Numbers => "ol", - designcraft_doc::ListType::Bullets => "ul", - designcraft_doc::ListType::None => "ul style=\"list-style:none\"", - }); - if list != list_tag { - close_list(&mut list, out); - if let Some(l) = list_tag { - let _ = writeln!(out, "<{l}>"); - } - list = list_tag; - } - if continues_pre { - out.push('\n'); - } else { - let id = match &self.heading_ids { - Some(prefix) if depth == 0 && heading_level(tag).is_some() => format!(" id=\"{prefix}-p{pi}\""), - _ => String::new(), - }; - let _ = write!(out, "<{tag}{id} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); - } - out.push_str(lead); - if lead.is_empty() && text.trim().is_empty() { - out.push_str(" "); - } - lead = ""; - let mut after = String::new(); - if !only_table { - self.runs(st, r, depth, out, &mut after); - } - if tag == "pre" { - // Closed when the next paragraph isn't a line of the same block. - pre = Some((class, rtl)); - } else { - let _ = writeln!(out, ""); - } - // Footnotes follow the paragraph outside any list or preformatted block. - if !after.is_empty() { - close_blocks(&mut list, &mut pre, out); - out.push_str(&after); - } - } - if let Some(t) = table - && depth < MAX_DEPTH - { - close_blocks(&mut list, &mut pre, out); - self.table(t, depth + 1, out); - } - } - close_blocks(&mut list, &mut pre, out); - } - - /// The markup for one run's text `raw` (starting at byte `a` of the story): anchored objects - /// and notes where their marks are. - fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String { - let doc = self.doc; - let mut t = String::new(); - let mut seg_start = 0; - // Index of the next anchored object: marks count from the story start, as - // `Story::object_at` counts them (counted once, at the run's first mark). - let mut mark: Option = None; - for (i, c) in raw.char_indices() { - if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK { - continue; - } - t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); - seg_start = i + c.len_utf8(); - if c == OBJECT_MARK { - let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count())); - mark = Some(k.saturating_add(1)); - if let Some(o) = st.objects.get(k) { - t.push_str(&self.object(o)); - } - } else if depth < MAX_DEPTH { - if c == FOOTNOTE_REF { - self.footnote(st, a + i, depth, &mut t, after); - } else { - self.endnote(st, a + i, depth, &mut t); - } - } - } - t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); - t - } - - /// The runs of paragraph `para` of `st`; footnotes go to `after`. - fn runs(&mut self, st: &Story, para: std::ops::Range, depth: usize, out: &mut String, after: &mut String) { - let doc = self.doc; - for (rr, f) in st.runs() { - let a = rr.start.max(para.start); - let b = rr.end.min(para.end); - if a >= b { - continue; - } - // Hidden conditional text isn't exported. - if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { - continue; - } - let Some(raw) = st.text.get(a..b) else { continue }; - let t = self.run_markup(st, raw, a, depth, after); - let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { - let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc); - (tag, format!(" class=\"{}\"", xml_attr(&class))) - } else { - ("span", String::new()) - }; - let style = override_css(doc, &f.over); - let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; - if cls.is_empty() && style_attr.is_empty() { - out.push_str(&t); - } else { - let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); - } - } - } - - /// An anchored object where it is anchored: its graphics in the text, an above-line object as - /// a block of its own. Anchored text frames are left out. - fn object(&mut self, o: &AnchoredObject) -> String { - let mut out = String::new(); - o.item.walk(&mut |it| { - if let Some((markup, _)) = self.graphic(it) { - out.push_str(&markup); - } - }); - match &o.position { - AnchorPosition::AboveLine { align, .. } if !out.is_empty() => { - let align = match align { - designcraft_doc::anchored::AnchorAlign::Left => "left", - designcraft_doc::anchored::AnchorAlign::Center => "center", - designcraft_doc::anchored::AnchorAlign::Right => "right", - }; - format!("{out}") - } - _ => out, - } - } - - /// The markup for one graphic item: an ``, or an HTML5 player for video and sound (with - /// `true`), its file registered in `images`. `None` for an item that isn't a graphic, whose - /// asset has no data, or when the writer names no files. - fn graphic(&mut self, it: &Item) -> Option<(String, bool)> { - let img_src = self.img_src?; - let doc = self.doc; - let Content::Graphic(g) = &it.content else { return None }; - let a = doc.assets.get(&g.asset)?; - if a.data.is_empty() { - return None; - } - let mime = safe_mime(&a.mime); - // Video and sound: an HTML5 player (with the poster, when there is one). - if let Some(kind) = designcraft_doc::media_kind(mime) { - let name = asset_path("media", a, mime); - self.images.insert(name.clone(), (mime, a.data.as_slice())); - let m = it.media.clone().unwrap_or_default(); - let mut attrs = String::new(); - for (on, attr) in [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] - { - if on { - attrs.push_str(attr); - } - } - if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { - let poster_mime = safe_mime(&pa.mime); - let pn = asset_path("images", pa, poster_mime); - self.images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); - if kind == "video" { - attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); - } - } - let markup = format!("<{kind} src=\"{}\"{attrs}>{}", xml_attr(&img_src(&name, mime, &a.data)), xml_text(&a.name)); - return Some((markup, true)); - } - let name = asset_path("images", a, mime); - self.images.insert(name.clone(), (mime, a.data.as_slice())); - // Alt text from Object Export Options; without one the image counts as decorative - // (`alt=""`): a file name read aloud helps nobody. - if it.alt_text.trim().is_empty() { - self.no_alt.push(a.name.clone()); - } - Some((format!("\"{}\"/", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(&it.alt_text)), false)) - } - - /// The footnote whose reference is at byte `pos` of `st`: a note reference in `out`, the note - /// itself in `after`. - fn footnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String, after: &mut String) { - let Some(note) = st.notes.get(st.notes_before(pos)) else { return }; - let doc = self.doc; - let o = &doc.footnote_options; - let n = self.next_footnote; - self.next_footnote = n.saturating_add(1); - self.footnote_ids = self.footnote_ids.saturating_add(1); - let id = self.footnote_ids; - let _ = write!( - out, - "{}", - self.epub_type("noteref"), - xml_text(&o.label(n, false)) - ); - let lead = format!("{} ", xml_text(&o.label(n, true))); - let mut body = String::new(); - self.paragraphs(¬e.text, depth + 1, &lead, &mut body); - let _ = write!(after, "\n{body}\n", self.epub_type("footnote")); - } - - /// The endnote whose reference is at byte `pos` of `st`: a note reference in `out`; the note - /// joins the endnotes section. - fn endnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String) { - let Some(note) = st.endnotes.get(st.endnotes_before(pos)) else { return }; - let n = self.next_endnote; - self.next_endnote = n.saturating_add(1); - self.endnote_ids = self.endnote_ids.saturating_add(1); - let id = self.endnote_ids; - let label = xml_text(&self.doc.endnote_options.label(n)); - let _ = write!(out, "{label}", self.epub_type("noteref")); - let mut body = String::new(); - self.paragraphs(¬e.text, depth + 1, &format!("{label} "), &mut body); - let item = format!("\n{body}\n", self.epub_type("endnote")); - self.endnotes.push((n, item)); - } - - /// The endnotes met so far, in number order, as one section (empty when there are none). - fn endnotes_section(&mut self) -> String { - if self.endnotes.is_empty() { - return String::new(); - } - self.endnotes.sort_by_key(|(n, _)| *n); - let doc = self.doc; - let o = &doc.endnote_options; - let mut out = format!("\n", self.epub_type("endnotes")); - if !o.heading.trim().is_empty() { - let (tag, class) = export_mapping(&doc.styles, &o.heading_style, false, &self.pc); - let _ = writeln!(out, "<{tag} class=\"{}\">{}", xml_attr(&class), xml_text(&o.heading)); - } - out.push_str("
      \n"); - for (_, item) in &self.endnotes { - out.push_str(item); - } - out.push_str("
    \n\n"); - out - } - - /// One table cell with its row and column spans, as `th` or `td`. - fn table_cell(&mut self, text: &Story, cell_tag: &str, (rs, cs): (usize, usize), depth: usize, out: &mut String) { - let _ = write!(out, "<{cell_tag}"); - if rs > 1 { - let _ = write!(out, " rowspan=\"{rs}\""); - } - if cs > 1 { - let _ = write!(out, " colspan=\"{cs}\""); - } - out.push('>'); - self.paragraphs(text, depth, "", out); - let _ = writeln!(out, ""); - } - - /// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column - /// spans (kept within their row group). - fn table(&mut self, t: &Table, depth: usize, out: &mut String) { - let (nr, nc) = (t.nrows(), t.ncols()); - if nr.checked_mul(nc) != Some(t.cells.len()) { - return; - } - let (h, f) = (t.header_rows(), t.footer_rows()); - let body_end = nr.saturating_sub(f).max(h); - let mut covered = vec![false; t.cells.len()]; - out.push_str("
    \n"); - for (rows, group, cell_tag) in [(0..h, "thead", "th"), (h..body_end, "tbody", "td"), (body_end..nr, "tfoot", "td")] { - if rows.is_empty() { - continue; - } - let _ = writeln!(out, "<{group}>"); - for r in rows.clone() { - out.push_str(""); - for c in 0..nc { - let i = r * nc + c; - if covered.get(i).copied().unwrap_or(true) { - continue; - } - let Some(cell) = t.cells.get(i) else { continue }; - let rs = (cell.row_span.max(1) as usize).min(rows.end - r); - let cs = (cell.col_span.max(1) as usize).min(nc - c); - for rr in r..r + rs { - for cc in c..c + cs { - if let Some(slot) = covered.get_mut(rr * nc + cc) { - *slot = true; - } - } - } - self.table_cell(&cell.text, cell_tag, (rs, cs), depth, out); - } - out.push_str("\n"); - } - let _ = writeln!(out, ""); - } - out.push_str("
    \n"); - } -} - -/// Run text with variables replaced by their values. -fn run_text(doc: &Document, raw: &str) -> String { - if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { - let sub: String = raw - .chars() - .map(|c| match designcraft_doc::vars::var_index(c) { - Some(i) => doc.variable_value(i, None).unwrap_or_default(), - None => c.to_string(), - }) - .collect(); - story_markup(&sub) - } else { - story_markup(raw) - } -} - -/// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to -/// it. Endnote references link to the endnotes section that the full export ends with. -pub fn story_html(doc: &Document, sid: StoryId) -> String { - StoryWriter::new(doc, true, None).story(sid, None) -} - -/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never -/// when it's off, before each paragraph of the one style it names, or before each paragraph of -/// a style whose Export Tagging has Split Document on. -fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool { - match split { - EpubSplit::Off => false, - EpubSplit::Style(name) => name == style, - EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split), - } -} - -/// Where the EPUB body splits into chapter files (see [`starts_chapter`]). -const CHAPTER_MARK: &str = ""; - -/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed -/// and the one a split goes through reopened in the next; a part with nothing but the start of a -/// section (a story that opens with its `h1`) moves that start into the next part. -fn split_chapters(body: &str) -> Vec { - let mut out: Vec = Vec::new(); - let mut carry: Option = None; - for piece in body.split(CHAPTER_MARK) { - let mut part = carry.take().unwrap_or_default(); - part.push_str(piece); - if let Some(open) = part.rfind("")) { - let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1); - if part[tag_end..].trim().is_empty() { - carry = Some(part[open..].to_string()); - part.truncate(open); - } else { - part.push_str("\n"); - carry = Some("
    \n".to_string()); - } - } - if !part.trim().is_empty() { - out.push(part); - } - } - if let Some(rest) = carry.filter(|c| !c.trim().is_empty()) - && let Some(last) = out.last_mut() - { - // Only an opening tag can be left over; close it where it stands. - last.push_str(&rest); - last.push_str("
    \n"); - } - if out.is_empty() { - out.push(String::new()); - } - out -} - -/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another -/// chapter file pointed at that file. -fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { - const HREF: &str = "href=\"#"; - let mut out = String::with_capacity(chapter.len()); - let mut rest = chapter; - while let Some(i) = rest.find(HREF) { - out.push_str(rest.get(..i).unwrap_or_default()); - let tail = rest.get(i + HREF.len()..).unwrap_or_default(); - let id = tail.split('"').next().unwrap_or_default(); - match files.get(id) { - Some(file) if file != own => { - let _ = write!(out, "href=\"{file}#"); - } - _ => out.push_str(HREF), - } - rest = tail; - } - out.push_str(rest); - out -} - -/// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. -fn chapter_file(k: usize) -> String { - if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } -} - -/// The level of a heading element (`h1` → 1 … `h6` → 6). -fn heading_level(tag: &str) -> Option { - tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) -} - -/// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in -/// reading order, or, in a document without headings, each story's first paragraph (level 1). -fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { - let pc = para_classes(&doc.styles); - let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::().trim().to_string() }; - let mut headings = Vec::new(); - let mut firsts = Vec::new(); - for (k, b) in order.iter().enumerate() { - let Block::Story(sid) = b else { continue }; - // The endnote story isn't written (the endnotes section stands in for it). - if doc.endnote_story == Some(*sid) { - continue; - } - let Some(st) = doc.story(*sid) else { continue }; - let first = label(st.text.split('\n').next().unwrap_or("")); - if !first.is_empty() { - firsts.push((format!("s{k}"), first, 1)); - } - for (pi, r) in st.para_ranges().iter().enumerate() { - let Some(pf) = st.paras.get(pi) else { continue }; - let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc); - let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue }; - let text = label(st.text.get(r.clone()).unwrap_or("")); - if !text.is_empty() { - headings.push((format!("s{k}-p{pi}"), text, level)); - } - } - } - if headings.is_empty() { firsts } else { headings } -} - -/// The table of contents as nested `li`/`ol` items (the content of the navigation's outer -/// `ol`): a deeper level goes into the previous item, skipped levels count as one. -fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { - let mut out = String::new(); - let mut open: Vec = Vec::new(); - for (target, label, level) in entries { - let file = files.get(target).map_or("content.xhtml", String::as_str); - let item = format!("
  • {}", xml_text(label)); - match open.last().copied() { - None => open.push(*level), - Some(top) if *level > top => { - out.push_str("
      "); - open.push(*level); - } - Some(_) => { - // Close the levels whose parent is as deep as this entry or deeper. - while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { - open.pop(); - out.push_str("
    "); - } - out.push_str("
  • "); - if let Some(t) = open.last_mut() { - *t = (*t).min(*level); - } - } - } - out.push_str(&item); - } - for i in (0..open.len()).rev() { - out.push_str(""); - if i > 0 { - out.push_str(""); - } - } - out -} - -/// Reading order of document content: (page, y, x) of each story's first frame and of graphics. -#[derive(Debug, Clone, PartialEq)] -pub enum Block { - Story(StoryId), - Image(ItemId), -} - -pub fn reading_order(doc: &Document) -> Vec { - // Articles, when there are any to export, decide the order (and what's included). - if doc.articles.iter().any(|a| a.export) { - let mut out = Vec::new(); - let mut seen = Vec::new(); - for a in doc.articles.iter().filter(|a| a.export) { - for id in &a.items { - let Some(top) = doc.item(*id) else { continue }; - let mut push = |it: &Item| match &it.content { - Content::Text(tf) if !seen.contains(&tf.story) => { - seen.push(tf.story); - out.push(Block::Story(tf.story)); - } - Content::Graphic(_) => out.push(Block::Image(it.id)), - _ => {} - }; - top.walk(&mut |i| push(i)); - } - } - return out; - } - let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); - let mut seen_stories = Vec::new(); - for (si, sp) in doc.spreads.iter().enumerate() { - let first = doc.first_page_of_spread(si); - // Preserve traversal order while carrying each group's transform to spread space. - // The iterator stack grows with nesting depth, without repeated document lookups. - let mut stack = vec![(sp.items.iter(), Affine::IDENTITY)]; - while let Some((items, parent)) = stack.last_mut() { - let Some(it) = items.next() else { - stack.pop(); - continue; - }; - let xf = *parent * it.xf; - let children = it.children(); - if !children.is_empty() { - stack.push((children.iter(), xf)); - } - if it.hidden { - continue; - } - let block = match &it.content { - Content::Text(tf) => { - let Some(story) = doc.story(tf.story) else { continue }; - // A story is placed where its first frame is. - if story.frames.first() != Some(&it.id) || seen_stories.contains(&tf.story) || story.text.trim().is_empty() { - continue; - } - seen_stories.push(tf.story); - Block::Story(tf.story) - } - Content::Graphic(_) => Block::Image(it.id), - _ => continue, - }; - // Transform the path once: rotating a local axis-aligned box again would - // enlarge it and give a different top-left from the actual spread geometry. - let b = it.path.transformed(xf).bounds().unwrap_or(Rect::ZERO); - let page = first + sp.page_at_x(b.center().x).unwrap_or(0); - let key = (page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64); - keyed.push((key, block)); - } - } - let _ = SpreadRef::Doc(0); - keyed.sort_by_key(|(k, _)| *k); - keyed.into_iter().map(|(_, b)| b).collect() -} - -/// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name -/// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same -/// name apart. -fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String { - let stem = a.name.rsplit(['/', '\\']).next().unwrap_or(""); - let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s); - let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); - let name: String = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(40).collect(); - if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) } -} - -fn ext(mime: &str) -> &'static str { - match mime { - "image/jpeg" => "jpg", - "image/gif" => "gif", - "image/webp" => "webp", - "image/svg+xml" => "svg", - "video/mp4" => "mp4", - "video/quicktime" => "mov", - "video/webm" => "webm", - "video/ogg" => "ogv", - "audio/mpeg" => "mp3", - "audio/mp4" => "m4a", - "audio/wav" => "wav", - "audio/ogg" => "ogg", - "audio/aac" => "aac", - _ => "png", - } -} - -/// The document content as HTML, with the files it names. -struct Body<'a> { - html: String, - /// Images, video and sound by package name: (media type, bytes). - images: BTreeMap, - /// Table of contents: (element id, label, level). - toc: Vec<(String, String, u8)>, - /// Asset names of the images written without alt text. - no_alt: Vec, -} - -/// The content in reading order as HTML sections and figures; `img_src` names each image -/// (a package path, or a data URI for single-file HTML). -fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { - let order = reading_order(doc); - let mut writer = StoryWriter::new(doc, epub, Some(img_src)); - if let Some(split) = split { - writer.split = split.clone(); - } - let mut body = String::new(); - let mut toc = toc_entries(doc, &order); - for (k, b) in order.iter().enumerate() { - match b { - // The endnote frame's text is generated from the endnotes, which end the body as a - // linked section instead. - Block::Story(sid) if doc.endnote_story == Some(*sid) => {} - Block::Story(sid) => { - let anchor = format!("s{k}"); - let _ = write!(body, "
    \n{}
    \n", writer.story(*sid, epub.then_some(anchor.as_str()))); - } - Block::Image(iid) => { - let Some(it) = doc.item(*iid) else { continue }; - let Some((markup, media)) = writer.graphic(it) else { continue }; - if media { - let _ = writeln!(body, "
    {markup}
    "); - continue; - } - // Object Export Options: alignment and a page break before. - let eo = &it.export_options; - let mut css = String::new(); - if matches!(eo.align.as_str(), "left" | "center" | "right") { - css.push_str(&format!("text-align:{};", eo.align)); - } - if eo.page_break_before { - css.push_str("page-break-before:always;break-before:page;"); - } - let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) }; - let _ = writeln!(body, "{markup}"); - } - } - } - let endnotes = writer.endnotes_section(); - if !endnotes.is_empty() { - let heading = &doc.endnote_options.heading; - if !heading.trim().is_empty() { - toc.push(("endnotes".into(), heading.clone(), 1)); - } - body.push_str(&endnotes); - } - Body { html: body, images: writer.images, toc, no_alt: writer.no_alt } -} - -/// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as -/// decorative (`alt=""`), which an export can report. -pub fn images_without_alt(doc: &Document) -> Vec { - body_html(doc, true, None, &|name, _, _| name.to_string()).no_alt -} - -/// Options for [`export_html`]. -#[derive(Clone, Debug, Default)] -pub struct HtmlOptions { - pub title: Option, - pub language: Option, - /// Generic CSS family per font family (see [`EpubOptions::generic_families`]). - pub generic_families: BTreeMap, -} - -/// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the -/// document's stories and graphics in reading order. -pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { - let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); - let language = opts.language.as_deref().unwrap_or("en"); - let lang = language_tag(language); - let body = body_html(doc, false, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; - format!( - "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", - xml_text(&title), - stylesheet_with(doc, &opts.generic_families, &[]) - ) -} - -fn base64(data: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() { - out.push(if i <= c.len() { T[(n >> shift & 63) as usize] as char } else { '=' }); - } - } - out -} - -/// Export the document as a reflowable EPUB 3. -pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); - let id = identifier(doc, opts, &title); - let modified = modified(doc, opts); - let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); - let lang = language_tag(&opts.language); - // One file per chapter (see `starts_chapter`); one file in all when Split Document is off. - let chapters: Vec = split_chapters(&body) - .into_iter() - .map(|part| { - format!( - "\n\n\n{}\n\n{part}\n\n", - xml_text(&title) - ) - }) - .collect(); - // The file each navigation target (an element id) ended up in. - let mut files: BTreeMap = BTreeMap::new(); - for (k, c) in chapters.iter().enumerate() { - for rest in c.split(" id=\"").skip(1) { - if let Some(id) = rest.split('"').next() { - files.entry(id.to_string()).or_insert_with(|| chapter_file(k)); - } - } - } - // Note links whose target ended up in another file (endnotes end the book) point at it. - let chapters: Vec = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect(); - let mut nav_items = nav_list(&toc, &files); - if nav_items.is_empty() { - nav_items = format!("
  • {}
  • ", xml_text(&title)); - } - let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title), - xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) - ); - let mut manifest = String::from( - "\n\n", - ); - let mut spine = String::new(); - for k in 0..chapters.len() { - let item = if k == 0 { "content".to_string() } else { format!("content{}", k + 1) }; - let _ = writeln!(manifest, "", chapter_file(k)); - let _ = writeln!(spine, ""); - } - for (k, (name, (mime, _))) in images.iter().enumerate() { - let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); - } - for (k, f) in opts.fonts.iter().enumerate() { - let (name, mime) = font_file(k, f); - let _ = writeln!(manifest, "", xml_attr(&name)); - } - // Cover: an image page first in reading order. - if let Some(c) = &opts.cover { - let (name, mime) = cover_file(c); - let _ = writeln!( - manifest, - "\n" - ); - } - let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), 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!( - "\n\n\n{}\n\"{}\"/\n\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)); - } - write_package(&opf, &nav, entries) -} - #[cfg(test)] -mod tests { - use super::*; - use designcraft_doc::build::NewDocument; - use designcraft_doc::geom::{Affine, Rect, shapes}; - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table}; - use std::io::Read; - use std::sync::Arc; - - #[test] - fn exports_valid_package_in_reading_order() { - let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); - let lid = d.default_layer(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second & co", ParaFormat::default()).unwrap(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap(); - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - assert_eq!(z.by_index(0).unwrap().name(), "mimetype"); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let a = html.find("First").unwrap(); - let b = html.find("Second <b> & co").unwrap(); - assert!(a < b, "reading order"); - assert!(html.contains("

    ")); - let mut css = String::new(); - z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); - assert!(css.contains("p.basic-paragraph")); - assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); - } - - /// #766: anchored graphics are exported where they are anchored (inline in the text, an - /// above-line object as a block of its own), and their images go into the package. - #[test] - fn anchored_objects_are_exported_in_place() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap(); - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - page: 0, - id: aid, - name: "dot.png".into(), - mime: "image/png".into(), - link: None, - data: Arc::new(vec![7]), - pixels: Some((1, 1)), - ..Default::default() - }), - ); - let graphic = |d: &mut Document, alt: &str| { - let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); - item.alt_text = alt.into(); - item.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (1.0, 1.0), - xf: Affine::IDENTITY, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - clip: None, - wrap: Default::default(), - fill: Default::default(), - }); - item - }; - let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); - let above = designcraft_doc::AnchoredObject::new( - graphic(&mut d, "above"), - designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 }, - ); - let st = d.story_mut(sid).unwrap(); - st.insert_object(1, inline); - // Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D. - st.insert_object(7, above); - // An anchored image without alt text is decorative and reported, like a placed one. - let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); - let end = d.story(sid).unwrap().text.len(); - d.story_mut(sid).unwrap().insert_object(end, bare); - assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]); - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let img = |alt: &str| format!("\"{alt}\"/", aid.0); - assert!(html.contains(&format!("A{}B

    ", img("inline"))), "{html}"); - assert!(html.contains(&format!("C{}D", img("above"))), "{html}"); - assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package"); - let page = export_html(&d, &HtmlOptions::default()); - assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}"); - // story_html on its own still leaves objects out. - assert!(!story_html(&d, sid).contains(" = z.file_names().map(str::to_string).collect(); - (s, names) - }; - let id_of = |opf: &str| opf.split("").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string(); - let mut a = Document::new(&NewDocument::default()); - a.title = "Same".into(); - a.created = 1_700_000_000; - let mut b = a.clone(); - b.created = 1_700_000_001; - let (oa, _) = opf(&a, &EpubOptions::default()); - let (oa2, _) = opf(&a, &EpubOptions::default()); - let (ob, _) = opf(&b, &EpubOptions::default()); - assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa)); - assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export"); - assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title"); - // Modified: the given time, else the document's save (or creation) time. - assert!(oa.contains("2023-11-14T22:13:20Z"), "{oa}"); - let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() }; - assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z")); - // A JPEG cover is packaged as such. - let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() }; - let (o, names) = opf(&a, &jpeg); - assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}"); - assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}"); - } - - fn st_typed(d: &mut Document, sid: StoryId) { - if let Some(st) = d.story_mut(sid) { - st.paras[8].style = "Typed".into(); - } - } - - /// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and - /// consecutive `pre` paragraphs of one style one block with their lines on separate lines. - #[test] - fn lists_and_preformatted_blocks_are_grouped() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - for (name, tag, list) in [ - ("Bullet", "li", Some(designcraft_doc::ListType::Bullets)), - ("Number", "li", Some(designcraft_doc::ListType::Numbers)), - ("Typed", "li", None), - ("Code", "pre", None), - ] { - let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() }; - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: name.into(), - based_on: None, - next_style: None, - para, - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut() - .export_tags - .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); - } - let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); - let st = d.story_mut(sid).unwrap(); - for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() { - st.paras[i + 1].style = style.into(); - } - let html = story_html(&d, sid); - let li = |c: &str, t: &str| format!("
  • {t}
  • "); - assert!( - html.contains(&format!( - "
      \n{}\n{}\n
    \n
      \n{}\n{}\n
    \n", - li("bullet", "first"), - li("bullet", "second"), - li("number", "one"), - li("number", "two") - )), - "{html}" - ); - assert!(html.contains("
    x = 1\ny = 2
    \n

    End

    "), "{html}"); - assert_eq!(html.matches("\n
  • • typed
  • \n\n"), "{html}"); - } - - /// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them, - /// with its title in the book's language; without headings, each story's first paragraph. - #[test] - fn navigation_follows_the_headings() { - let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); - let lid = d.default_layer(); - for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] { - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: name.into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut() - .export_tags - .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); - } - let text = "One\nBody\nOne point one\nMore\nTwo\nText"; - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); - let st = d.story_mut(sid).unwrap(); - for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] { - st.paras[i].style = style.into(); - } - // A story that starts with body text adds nothing to the table of contents. - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap(); - let read = |d: &Document, opts: &EpubOptions, path: &str| { - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let nl = EpubOptions { language: "nl".into(), ..Default::default() }; - let nav = read(&d, &nl, "OEBPS/nav.xhtml"); - let link = |f: &str, t: &str, l: &str| format!("{l}"); - // Split Document is off: every heading is in the one content file. - let want = format!( - "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", - link("content.xhtml", "s0-p0", "One"), - link("content.xhtml", "s0-p2", "One point one"), - link("content.xhtml", "s0-p4", "Two") - ); - assert!(nav.contains(&want), "{nav}"); - assert!(!nav.contains("Text after the figure"), "{nav}"); - let content = read(&d, &nl, "OEBPS/content.xhtml"); - assert!(content.contains("

    One

    ") && content.contains("

    In dit boek

    ")); - // Without headings: one entry per story, its first paragraph. - let mut plain = Document::new(&NewDocument::default()); - let lid = plain.default_layer(); - plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); - let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); - assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("content.xhtml", "s0", "Opening"))), "{nav}"); - // A heading shallower than the one before it but deeper than its parent stays under the - // parent: h1 A, h3 B, h2 C nest B and C under A. - let files: BTreeMap = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect(); - let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); - assert_eq!( - nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files), - format!( - "
  • {}
    1. {}
    2. {}
  • {}
  • ", - link("A", "A", "A"), - link("B", "B", "B"), - link("C", "C", "C"), - link("D", "D", "D") - ) - ); - assert_eq!( - nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files), - format!( - "
  • {}
    1. {}
      1. {}
  • {}
  • ", - link("A", "A", "A"), - link("B", "B", "B"), - link("C", "C", "C"), - link("D", "D", "D") - ) - ); - } - - /// Split Document off (the default) gives one content file, h1s and all. Single Paragraph - /// Style starts a file before each paragraph of that style, with well-formed sections; the - /// spine and manifest list the files in order. - #[test] - fn chapters_split_before_a_single_paragraph_style() { - let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); - let lid = d.default_layer(); - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: "Chapter".into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut() - .export_tags - .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); - // A title page without a heading, then a story that opens with a chapter and has a - // second one in the middle. - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); - let (_, sid) = d - .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) - .unwrap(); - let st = d.story_mut(sid).unwrap(); - st.paras[0].style = "Chapter".into(); - st.paras[2].style = "Chapter".into(); - let one_file = |d: &Document, opts: &EpubOptions| { - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); - let mut c = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap(); - z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK) - }; - assert!(one_file(&d, &EpubOptions::default()), "off by default"); - d.settings.epub_split = EpubSplit::Style("Chapter".into()); - assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins"); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); - let mut read = |path: &str| { - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); - assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}"); - assert!(c2.contains("
    \n

    \n

    ").count(), "{c}"); - assert!(!c.contains(CHAPTER_MARK)); - } - let opf = read("OEBPS/content.opf"); - assert!(opf.contains("\n\n\n"), "{opf}"); - assert!(opf.contains("").count(), "{c}"); - } - let nav = read("OEBPS/nav.xhtml"); - assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}"); - assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}"); - assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}"); - assert!(z.by_name("OEBPS/content4.xhtml").is_err()); - } - - /// Note links stay working across chapter files: endnotes end the book, in the last file. - #[test] - fn note_links_point_at_the_chapter_file_of_their_target() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: "Chapter".into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut() - .export_tags - .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); - let (_, sid) = d - .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) - .unwrap(); - let st = d.story_mut(sid).unwrap(); - st.paras[0].style = "Chapter".into(); - st.paras[2].style = "Chapter".into(); - // After "First": an endnote and a footnote. - st.insert_endnote(9, "End note", ParaFormat::default()); - st.insert_note(9, "Foot note", ParaFormat::default()); - d.settings.epub_split = EpubSplit::Style("Chapter".into()); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); - let mut read = |path: &str| { - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let (first, last) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml")); - assert!(first.contains("id=\"enref-1\" href=\"content2.xhtml#en-1\""), "{first}"); - assert!(first.contains("href=\"#fn-1\"") && first.contains("href=\"#fnref-1\""), "a footnote stays in its chapter: {first}"); - assert!(last.contains("Second text") && last.contains(""), "{last}"); - } - - /// A split before a list item or a preformatted line closes the open list or block first and - /// reopens it in the new file, so every file is balanced. - #[test] - fn chapter_files_split_inside_lists_and_pre_stay_balanced() { - let mut d = Document::new(&NewDocument::default()); - d.settings.epub_split = EpubSplit::ExportTags; - let lid = d.default_layer(); - for (name, tag) in [("Item", "li"), ("Code", "pre")] { - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: name.into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() }); - } - let (_, sid) = d - .add_text_frame( - SpreadRef::Doc(0), - Rect::new(36.0, 36.0, 300.0, 300.0), - lid, - "Intro -one -two -x = 1 -y = 2 -End", - ParaFormat::default(), - ) - .unwrap(); - let st = d.story_mut(sid).unwrap(); - for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] { - st.paras[i].style = style.into(); - } - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); - let mut read = |path: &str| { - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let files: Vec = - ["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect(); - for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) { - assert!(c.contains(text), "{text}: {c}"); - for (open, close) in [(""), (""), ("")] { - assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}"); - } - } - assert!(files[1].contains("\n

    a

    \n

    \n"), vec!["
    \n

    a

    \n
    \n"]); - assert_eq!( - split_chapters(&format!("
    \n{m}

    A

    \n
    \n")), - vec!["
    \n

    A

    \n
    \n"] - ); - assert_eq!( - split_chapters(&format!("
    \n

    a

    \n{m}

    B

    \n
    \n")), - vec!["
    \n

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] - ); - assert_eq!(split_chapters(""), vec![String::new()]); - } - - #[test] - fn styles_whose_names_slug_alike_keep_their_own_classes() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] { - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: name.into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs { size: Some(size), ..Default::default() }, - shortcut: String::new(), - }); - } - for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() { - let y = 36.0 + 100.0 * k as f64; - let pf = ParaFormat { style: name.into(), ..Default::default() }; - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap(); - } - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let mut css = String::new(); - z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); - let class_of = |text: &str| { - let end = html.find(&format!("\">{text}

    ")).unwrap(); - let start = html[..end].rfind("class=\"").unwrap() + 7; - html[start..end].to_string() - }; - let classes: Vec = (0..4).map(|k| class_of(&format!("P{k}"))).collect(); - for (k, c) in classes.iter().enumerate() { - assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}"); - assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}"); - } - } - - #[test] - fn slugs() { - assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph"); - assert_eq!(slug("Body First"), "body-first"); - assert_eq!(slug("1 Head"), "s-1-head"); - } - - #[test] - fn html_is_one_self_contained_page() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello ", ParaFormat::default()).unwrap(); - let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None, ..Default::default() }); - assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); - assert!(html.contains("p.basic-paragraph"), "styles inline"); - assert_eq!(base64(b"Man"), "TWFu"); - assert_eq!(base64(b"Ma"), "TWE="); - assert_eq!(base64(b"M"), "TQ=="); - } - - /// #774: an image without alt text is decorative (`alt=""`) and reported, and package names - /// come from the original file names. - #[test] - fn images_get_readable_names_and_no_file_name_alt() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| { - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - page: 0, - id: aid, - name: name.into(), - mime: mime.into(), - link: None, - data: Arc::new(vec![1]), - pixels: Some((1, 1)), - ..Default::default() - }), - ); - let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); - item.alt_text = alt.into(); - item.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (1.0, 1.0), - xf: Affine::IDENTITY, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - clip: None, - wrap: Default::default(), - fill: Default::default(), - }); - d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - aid.0 - }; - let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0); - let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0); - let c = place(&mut d, "???.png", "image/png", "Mark", 200.0); - assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); - let mut x = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap(); - assert!(x.contains(&format!("\"\"/")), "{x}"); - assert!(x.contains(&format!("\"A")), "{x}"); - assert!(x.contains(&format!("\"Mark\"/")), "{x}"); - assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok()); - } - - /// #775: the generic fallback follows the font, and embedded fonts are packaged and declared. - #[test] - fn generic_fallbacks_and_embedded_fonts() { - let none = BTreeMap::new(); - for (family, want) in [ - ("Source Serif 4", "serif"), - ("Source Sans 3", "sans-serif"), - ("Microsoft Sans Serif", "sans-serif"), - ("JetBrains Mono", "monospace"), - ("Courier New", "monospace"), - ("Franklin Gothic", "sans-serif"), - ("Garamond", "serif"), - ] { - assert_eq!(generic_family(family, &none), want, "{family}"); - } - let known: BTreeMap = - [("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into(); - assert_eq!(generic_family("Montserrat", &known), "sans-serif"); - assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used"); - assert_eq!(css_weight_style("Semibold Italic"), (600, "italic")); - assert_eq!(css_weight_style("Bold"), (700, "normal")); - assert_eq!(css_weight_style("Extra Bold"), (800, "normal")); - assert_eq!(css_weight_style("Regular"), (400, "normal")); - assert_eq!(css_weight_style("Light"), (300, "normal")); - assert_eq!(css_weight_style("Thin Oblique"), (100, "italic")); - assert_eq!(css_weight_style("Black"), (900, "normal")); - - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); - if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { - basic.chars.font_family = Some("Montserrat".into()); - basic.chars.font_style = Some("Bold Italic".into()); - } - assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]); - let opts = EpubOptions { - generic_families: known, - fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }], - ..Default::default() - }; - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap(); - let mut read = |path: &str| { - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let css = read("OEBPS/style.css"); - assert!( - css.starts_with( - "@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }" - ), - "{css}" - ); - assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}"); - let opf = read("OEBPS/content.opf"); - assert!(opf.contains(""), "{opf}"); - assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font"); - // HTML takes the generic families too, and embeds nothing. - let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() }); - assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}"); - } - - #[test] - fn metadata_is_serialized_for_its_output_context() { - let mut d = Document::new(&NewDocument::default()); - d.title = "Field Notes & ".into(); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B \"D\"", ParaFormat::default()).unwrap(); - d.styles_mut().export_tags.insert( - format!("p:{}", story::BASIC_PARAGRAPH), - designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() }, - ); - if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { - basic.chars.font_family = Some("Quoted \"Serif\" ".into()); - } - - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - id: aid, - name: "Cover & Notes".into(), - mime: "image/png; profile=screen".into(), - data: Arc::new(vec![1]), - pixels: Some((1, 1)), - ..Default::default() - }), - ); - let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); - item.alt_text = "Cover \"A\" & B".into(); - item.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (1.0, 1.0), - xf: Affine::IDENTITY, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - - let story = story_html(&d, sid); - assert!(story.contains("

    A&B <C> "D"

    ")); - let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() }); - assert!(html.contains("")); - assert!(html.contains("Field Notes & <Edition>")); - assert!(html.contains("alt=\"Cover "A" & B\"")); - assert!(html.contains("src=\"data:image/png;base64,AQ==\"")); - assert!(!html.contains("image/png; profile=screen")); - assert!(!stylesheet(&d).contains("")); - assert!(stylesheet(&d).contains("\\3c Alt\\3e ")); - - let bytes = export_epub( - &d, - &EpubOptions { - title: None, - author: Some("A & B".into()), - language: "English (US)".into(), - identifier: Some("notes&edition".into()), - cover: None, - ..Default::default() - }, - ) - .unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut opf = String::new(); - z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); - assert!(opf.contains("xml:lang=\"und\"")); - assert!(opf.contains("notes&edition")); - assert!(opf.contains("A & B")); - assert!(opf.contains("media-type=\"image/png\"")); - } - - /// File › File Info's Document Title and Author name the EPUB and the HTML page; the export - /// options' title and author replace them. - #[test] - fn file_info_titles_the_epub_and_the_html_page() { - let mut d = Document::new(&NewDocument { title: "flyer-v3".into(), ..Default::default() }); - d.file_info.document_title = "Spring & Summer".into(); - d.file_info.author = "Ana Ruiz".into(); - let opf = |d: &Document, opts: &EpubOptions| { - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); - let mut opf = String::new(); - z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); - opf - }; - let o = opf(&d, &EpubOptions::default()); - assert!(o.contains("Spring & Summer") && o.contains("Ana Ruiz"), "{o}"); - let o = opf(&d, &EpubOptions { title: Some("Proof".into()), author: Some("Studio".into()), ..Default::default() }); - assert!(o.contains("Proof") && o.contains("Studio"), "{o}"); - let html = export_html(&d, &HtmlOptions::default()); - assert!(html.contains("Spring & Summer")); - d.file_info = Default::default(); - let o = opf(&d, &EpubOptions::default()); - assert!(o.contains("flyer-v3") && !o.contains(""), "{o}"); - } - - /// "Body¹ text", a 2×2 table, then "Closing" with an endnote. - fn tables_and_notes() -> Document { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap(); - let st = d.story_mut(sid).unwrap(); - st.insert_note(4, "Foot note text", ParaFormat::default()); - let mut t = Table::new(1, 2, 2, 0, 0, 200.0); - for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] { - t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default()); - } - let end = st.len(); - st.insert_table(end, t); - let end = st.len(); - st.insert(end, "Closing"); - let end = st.len(); - st.insert_endnote(end, "End note text", ParaFormat::default()); - d - } - - #[test] - fn tables_footnotes_and_endnotes_are_exported() { - let d = tables_and_notes(); - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let (t0, t1) = (html.find("").unwrap(), html.find("
    ").unwrap()); - for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] { - let at = html.find(&format!("

    {cell}

    \n")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}")); - assert!(t0 < at && at < t1); - } - assert_eq!(html.matches("").count(), 2); - - let noteref = "Body
    1 text"; - assert!(html.contains(noteref), "footnote reference:\n{html}"); - let aside = html.find("").unwrap(); - assert!(html[aside..aside_end].contains("1 Foot note text")); - assert!(aside < t0, "the footnote follows its paragraph"); - - assert!(html.contains("Closing1")); - let section = html.find("
    ").unwrap(); - assert!(section > html.find("Closing").unwrap()); - assert!(html[section..].contains("
  • ")); - assert!(html[section..].contains("1 End note text")); - let mut nav = String::new(); - z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap(); - assert!(nav.contains("href=\"content.xhtml#endnotes\"")); - - let page = export_html(&d, &HtmlOptions::default()); - assert!(page.contains("\n", self.epub_type("footnote")); + } + + /// The endnote whose reference is at byte `pos` of `st`: a note reference in `out`; the note + /// joins the endnotes section. + fn endnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String) { + let Some(note) = st.endnotes.get(st.endnotes_before(pos)) else { return }; + let n = self.next_endnote; + self.next_endnote = n.saturating_add(1); + self.endnote_ids = self.endnote_ids.saturating_add(1); + let id = self.endnote_ids; + let label = xml_text(&self.doc.endnote_options.label(n)); + let _ = write!(out, "{label}", self.epub_type("noteref")); + let mut body = String::new(); + self.paragraphs(¬e.text, depth + 1, &format!("{label} "), &mut body); + let item = format!("\n{body}
  • \n", self.epub_type("endnote")); + self.endnotes.push((n, item)); + } + + /// The endnotes met so far, in number order, as one section (empty when there are none). + pub(crate) fn endnotes_section(&mut self) -> String { + if self.endnotes.is_empty() { + return String::new(); + } + self.endnotes.sort_by_key(|(n, _)| *n); + let doc = self.doc; + let o = &doc.endnote_options; + let mut out = format!("\n", self.epub_type("endnotes")); + if !o.heading.trim().is_empty() { + let (tag, class) = export_mapping(&doc.styles, &o.heading_style, false, &self.pc); + let _ = writeln!(out, "<{tag} class=\"{}\">{}", xml_attr(&class), xml_text(&o.heading)); + } + out.push_str("
      \n"); + for (_, item) in &self.endnotes { + out.push_str(item); + } + out.push_str("
    \n
    \n"); + out + } + + /// One table cell with its row and column spans, as `th` or `td`. + fn table_cell(&mut self, text: &Story, cell_tag: &str, (rs, cs): (usize, usize), depth: usize, out: &mut String) { + let _ = write!(out, "<{cell_tag}"); + if rs > 1 { + let _ = write!(out, " rowspan=\"{rs}\""); + } + if cs > 1 { + let _ = write!(out, " colspan=\"{cs}\""); + } + out.push('>'); + self.paragraphs(text, depth, "", out); + let _ = writeln!(out, ""); + } + + /// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column + /// spans (kept within their row group). + fn table(&mut self, t: &Table, depth: usize, out: &mut String) { + let (nr, nc) = (t.nrows(), t.ncols()); + if nr.checked_mul(nc) != Some(t.cells.len()) { + return; + } + let (h, f) = (t.header_rows(), t.footer_rows()); + let body_end = nr.saturating_sub(f).max(h); + let mut covered = vec![false; t.cells.len()]; + out.push_str("\n"); + for (rows, group, cell_tag) in [(0..h, "thead", "th"), (h..body_end, "tbody", "td"), (body_end..nr, "tfoot", "td")] { + if rows.is_empty() { + continue; + } + let _ = writeln!(out, "<{group}>"); + for r in rows.clone() { + out.push_str(""); + for c in 0..nc { + let i = r * nc + c; + if covered.get(i).copied().unwrap_or(true) { + continue; + } + let Some(cell) = t.cells.get(i) else { continue }; + let rs = (cell.row_span.max(1) as usize).min(rows.end - r); + let cs = (cell.col_span.max(1) as usize).min(nc - c); + for rr in r..r + rs { + for cc in c..c + cs { + if let Some(slot) = covered.get_mut(rr * nc + cc) { + *slot = true; + } + } + } + self.table_cell(&cell.text, cell_tag, (rs, cs), depth, out); + } + out.push_str("\n"); + } + let _ = writeln!(out, ""); + } + out.push_str("
    \n"); + } +} + +/// Run text with variables replaced by their values. +fn run_text(doc: &Document, raw: &str) -> String { + if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { + let sub: String = raw + .chars() + .map(|c| match designcraft_doc::vars::var_index(c) { + Some(i) => doc.variable_value(i, None).unwrap_or_default(), + None => c.to_string(), + }) + .collect(); + story_markup(&sub) + } else { + story_markup(raw) + } +} + +/// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to +/// it. Endnote references link to the endnotes section that the full export ends with. +pub fn story_html(doc: &Document, sid: StoryId) -> String { + StoryWriter::new(doc, true, None).story(sid, None) +} diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs new file mode 100644 index 0000000000..f216fe95f8 --- /dev/null +++ b/crates/epub/src/tests.rs @@ -0,0 +1,924 @@ +use super::*; +use designcraft_doc::build::NewDocument; +use designcraft_doc::geom::{Affine, Rect, shapes}; +use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table}; +use std::io::Read; +use std::sync::Arc; + +#[test] +fn exports_valid_package_in_reading_order() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second & co", ParaFormat::default()).unwrap(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap(); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + assert_eq!(z.by_index(0).unwrap().name(), "mimetype"); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let a = html.find("First").unwrap(); + let b = html.find("Second <b> & co").unwrap(); + assert!(a < b, "reading order"); + assert!(html.contains("

    ")); + let mut css = String::new(); + z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); + assert!(css.contains("p.basic-paragraph")); + assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); +} + +/// #766: anchored graphics are exported where they are anchored (inline in the text, an +/// above-line object as a block of its own), and their images go into the package. +#[test] +fn anchored_objects_are_exported_in_place() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap(); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + page: 0, + id: aid, + name: "dot.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(vec![7]), + pixels: Some((1, 1)), + ..Default::default() + }), + ); + let graphic = |d: &mut Document, alt: &str| { + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + clip: None, + wrap: Default::default(), + fill: Default::default(), + }); + item + }; + let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); + let above = designcraft_doc::AnchoredObject::new( + graphic(&mut d, "above"), + designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 }, + ); + let st = d.story_mut(sid).unwrap(); + st.insert_object(1, inline); + // Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D. + st.insert_object(7, above); + // An anchored image without alt text is decorative and reported, like a placed one. + let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); + let end = d.story(sid).unwrap().text.len(); + d.story_mut(sid).unwrap().insert_object(end, bare); + assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let img = |alt: &str| format!("\"{alt}\"/", aid.0); + assert!(html.contains(&format!("A{}B

    ", img("inline"))), "{html}"); + assert!(html.contains(&format!("C{}D", img("above"))), "{html}"); + assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package"); + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}"); + // story_html on its own still leaves objects out. + assert!(!story_html(&d, sid).contains(" = z.file_names().map(str::to_string).collect(); + (s, names) + }; + let id_of = |opf: &str| opf.split("").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string(); + let mut a = Document::new(&NewDocument::default()); + a.title = "Same".into(); + a.created = 1_700_000_000; + let mut b = a.clone(); + b.created = 1_700_000_001; + let (oa, _) = opf(&a, &EpubOptions::default()); + let (oa2, _) = opf(&a, &EpubOptions::default()); + let (ob, _) = opf(&b, &EpubOptions::default()); + assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa)); + assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export"); + assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title"); + // Modified: the given time, else the document's save (or creation) time. + assert!(oa.contains("2023-11-14T22:13:20Z"), "{oa}"); + let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() }; + assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z")); + // A JPEG cover is packaged as such. + let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() }; + let (o, names) = opf(&a, &jpeg); + assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}"); + assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}"); +} + +fn st_typed(d: &mut Document, sid: StoryId) { + if let Some(st) = d.story_mut(sid) { + st.paras[8].style = "Typed".into(); + } +} + +/// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and +/// consecutive `pre` paragraphs of one style one block with their lines on separate lines. +#[test] +fn lists_and_preformatted_blocks_are_grouped() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + for (name, tag, list) in [ + ("Bullet", "li", Some(designcraft_doc::ListType::Bullets)), + ("Number", "li", Some(designcraft_doc::ListType::Numbers)), + ("Typed", "li", None), + ("Code", "pre", None), + ] { + let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() }; + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para, + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); + } + let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() { + st.paras[i + 1].style = style.into(); + } + let html = story_html(&d, sid); + let li = |c: &str, t: &str| format!("
  • {t}
  • "); + assert!( + html.contains(&format!( + "
      \n{}\n{}\n
    \n
      \n{}\n{}\n
    \n", + li("bullet", "first"), + li("bullet", "second"), + li("number", "one"), + li("number", "two") + )), + "{html}" + ); + assert!(html.contains("
    x = 1\ny = 2
    \n

    End

    "), "{html}"); + assert_eq!(html.matches("\n
  • • typed
  • \n\n"), "{html}"); +} + +/// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them, +/// with its title in the book's language; without headings, each story's first paragraph. +#[test] +fn navigation_follows_the_headings() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); + } + let text = "One\nBody\nOne point one\nMore\nTwo\nText"; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] { + st.paras[i].style = style.into(); + } + // A story that starts with body text adds nothing to the table of contents. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap(); + let read = |d: &Document, opts: &EpubOptions, path: &str| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let nl = EpubOptions { language: "nl".into(), ..Default::default() }; + let nav = read(&d, &nl, "OEBPS/nav.xhtml"); + let link = |f: &str, t: &str, l: &str| format!("{l}"); + // Split Document is off: every heading is in the one content file. + let want = format!( + "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", + link("content.xhtml", "s0-p0", "One"), + link("content.xhtml", "s0-p2", "One point one"), + link("content.xhtml", "s0-p4", "Two") + ); + assert!(nav.contains(&want), "{nav}"); + assert!(!nav.contains("Text after the figure"), "{nav}"); + let content = read(&d, &nl, "OEBPS/content.xhtml"); + assert!(content.contains("

    One

    ") && content.contains("

    In dit boek

    ")); + // Without headings: one entry per story, its first paragraph. + let mut plain = Document::new(&NewDocument::default()); + let lid = plain.default_layer(); + plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); + let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); + assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("content.xhtml", "s0", "Opening"))), "{nav}"); + // A heading shallower than the one before it but deeper than its parent stays under the + // parent: h1 A, h3 B, h2 C nest B and C under A. + let files: BTreeMap = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect(); + let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); + assert_eq!( + nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files), + format!( + "
  • {}
    1. {}
    2. {}
  • {}
  • ", + link("A", "A", "A"), + link("B", "B", "B"), + link("C", "C", "C"), + link("D", "D", "D") + ) + ); + assert_eq!( + nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files), + format!( + "
  • {}
    1. {}
      1. {}
  • {}
  • ", + link("A", "A", "A"), + link("B", "B", "B"), + link("C", "C", "C"), + link("D", "D", "D") + ) + ); +} + +/// Split Document off (the default) gives one content file, h1s and all. Single Paragraph +/// Style starts a file before each paragraph of that style, with well-formed sections; the +/// spine and manifest list the files in order. +#[test] +fn chapters_split_before_a_single_paragraph_style() { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let lid = d.default_layer(); + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: "Chapter".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); + // A title page without a heading, then a story that opens with a chapter and has a + // second one in the middle. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); + let (_, sid) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].style = "Chapter".into(); + st.paras[2].style = "Chapter".into(); + let one_file = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut c = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap(); + z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK) + }; + assert!(one_file(&d, &EpubOptions::default()), "off by default"); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); + assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); + assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}"); + assert!(c2.contains("
    \n

    \n

    ").count(), "{c}"); + assert!(!c.contains(CHAPTER_MARK)); + } + let opf = read("OEBPS/content.opf"); + assert!(opf.contains("\n\n\n"), "{opf}"); + assert!(opf.contains("").count(), "{c}"); + } + let nav = read("OEBPS/nav.xhtml"); + assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}"); + assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}"); + assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}"); + assert!(z.by_name("OEBPS/content4.xhtml").is_err()); +} + +/// Note links stay working across chapter files: endnotes end the book, in the last file. +#[test] +fn note_links_point_at_the_chapter_file_of_their_target() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: "Chapter".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); + let (_, sid) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].style = "Chapter".into(); + st.paras[2].style = "Chapter".into(); + // After "First": an endnote and a footnote. + st.insert_endnote(9, "End note", ParaFormat::default()); + st.insert_note(9, "Foot note", ParaFormat::default()); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let (first, last) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml")); + assert!(first.contains("id=\"enref-1\" href=\"content2.xhtml#en-1\""), "{first}"); + assert!(first.contains("href=\"#fn-1\"") && first.contains("href=\"#fnref-1\""), "a footnote stays in its chapter: {first}"); + assert!(last.contains("Second text") && last.contains(""), "{last}"); +} + +/// A split before a list item or a preformatted line closes the open list or block first and +/// reopens it in the new file, so every file is balanced. +#[test] +fn chapter_files_split_inside_lists_and_pre_stay_balanced() { + let mut d = Document::new(&NewDocument::default()); + d.settings.epub_split = EpubSplit::ExportTags; + let lid = d.default_layer(); + for (name, tag) in [("Item", "li"), ("Code", "pre")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() }); + } + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 300.0), + lid, + "Intro +one +two +x = 1 +y = 2 +End", + ParaFormat::default(), + ) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] { + st.paras[i].style = style.into(); + } + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let files: Vec = ["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect(); + for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) { + assert!(c.contains(text), "{text}: {c}"); + for (open, close) in [(""), (""), ("")] { + assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}"); + } + } + assert!(files[1].contains("\n

    a

    \n

    \n"), vec!["
    \n

    a

    \n
    \n"]); + assert_eq!(split_chapters(&format!("
    \n{m}

    A

    \n
    \n")), vec!["
    \n

    A

    \n
    \n"]); + assert_eq!( + split_chapters(&format!("
    \n

    a

    \n{m}

    B

    \n
    \n")), + vec!["
    \n

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] + ); + assert_eq!(split_chapters(""), vec![String::new()]); +} + +#[test] +fn styles_whose_names_slug_alike_keep_their_own_classes() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs { size: Some(size), ..Default::default() }, + shortcut: String::new(), + }); + } + for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() { + let y = 36.0 + 100.0 * k as f64; + let pf = ParaFormat { style: name.into(), ..Default::default() }; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap(); + } + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let mut css = String::new(); + z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); + let class_of = |text: &str| { + let end = html.find(&format!("\">{text}

    ")).unwrap(); + let start = html[..end].rfind("class=\"").unwrap() + 7; + html[start..end].to_string() + }; + let classes: Vec = (0..4).map(|k| class_of(&format!("P{k}"))).collect(); + for (k, c) in classes.iter().enumerate() { + assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}"); + assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}"); + } +} + +#[test] +fn slugs() { + assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph"); + assert_eq!(slug("Body First"), "body-first"); + assert_eq!(slug("1 Head"), "s-1-head"); +} + +#[test] +fn html_is_one_self_contained_page() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello ", ParaFormat::default()).unwrap(); + let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None, ..Default::default() }); + assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); + assert!(html.contains("p.basic-paragraph"), "styles inline"); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); + assert_eq!(base64(b"M"), "TQ=="); +} + +/// #774: an image without alt text is decorative (`alt=""`) and reported, and package names +/// come from the original file names. +#[test] +fn images_get_readable_names_and_no_file_name_alt() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| { + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + page: 0, + id: aid, + name: name.into(), + mime: mime.into(), + link: None, + data: Arc::new(vec![1]), + pixels: Some((1, 1)), + ..Default::default() + }), + ); + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + clip: None, + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + aid.0 + }; + let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0); + let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0); + let c = place(&mut d, "???.png", "image/png", "Mark", 200.0); + assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut x = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap(); + assert!(x.contains(&format!("\"\"/")), "{x}"); + assert!(x.contains(&format!("\"A")), "{x}"); + assert!(x.contains(&format!("\"Mark\"/")), "{x}"); + assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok()); +} + +/// #775: the generic fallback follows the font, and embedded fonts are packaged and declared. +#[test] +fn generic_fallbacks_and_embedded_fonts() { + let none = BTreeMap::new(); + for (family, want) in [ + ("Source Serif 4", "serif"), + ("Source Sans 3", "sans-serif"), + ("Microsoft Sans Serif", "sans-serif"), + ("JetBrains Mono", "monospace"), + ("Courier New", "monospace"), + ("Franklin Gothic", "sans-serif"), + ("Garamond", "serif"), + ] { + assert_eq!(generic_family(family, &none), want, "{family}"); + } + let known: BTreeMap = [("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into(); + assert_eq!(generic_family("Montserrat", &known), "sans-serif"); + assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used"); + assert_eq!(css_weight_style("Semibold Italic"), (600, "italic")); + assert_eq!(css_weight_style("Bold"), (700, "normal")); + assert_eq!(css_weight_style("Extra Bold"), (800, "normal")); + assert_eq!(css_weight_style("Regular"), (400, "normal")); + assert_eq!(css_weight_style("Light"), (300, "normal")); + assert_eq!(css_weight_style("Thin Oblique"), (100, "italic")); + assert_eq!(css_weight_style("Black"), (900, "normal")); + + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { + basic.chars.font_family = Some("Montserrat".into()); + basic.chars.font_style = Some("Bold Italic".into()); + } + assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]); + let opts = EpubOptions { + generic_families: known, + fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }], + ..Default::default() + }; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let css = read("OEBPS/style.css"); + assert!( + css.starts_with( + "@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }" + ), + "{css}" + ); + assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}"); + let opf = read("OEBPS/content.opf"); + assert!(opf.contains(""), "{opf}"); + assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font"); + // HTML takes the generic families too, and embeds nothing. + let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() }); + assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}"); +} + +#[test] +fn metadata_is_serialized_for_its_output_context() { + let mut d = Document::new(&NewDocument::default()); + d.title = "Field Notes & ".into(); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B \"D\"", ParaFormat::default()).unwrap(); + d.styles_mut().export_tags.insert( + format!("p:{}", story::BASIC_PARAGRAPH), + designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() }, + ); + if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { + basic.chars.font_family = Some("Quoted \"Serif\" ".into()); + } + + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name: "Cover & Notes".into(), + mime: "image/png; profile=screen".into(), + data: Arc::new(vec![1]), + pixels: Some((1, 1)), + ..Default::default() + }), + ); + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); + item.alt_text = "Cover \"A\" & B".into(); + item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + + let story = story_html(&d, sid); + assert!(story.contains("

    A&B <C> "D"

    ")); + let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() }); + assert!(html.contains("")); + assert!(html.contains("Field Notes & <Edition>")); + assert!(html.contains("alt=\"Cover "A" & B\"")); + assert!(html.contains("src=\"data:image/png;base64,AQ==\"")); + assert!(!html.contains("image/png; profile=screen")); + assert!(!stylesheet(&d).contains("")); + assert!(stylesheet(&d).contains("\\3c Alt\\3e ")); + + let bytes = export_epub( + &d, + &EpubOptions { + title: None, + author: Some("A & B".into()), + language: "English (US)".into(), + identifier: Some("notes&edition".into()), + cover: None, + ..Default::default() + }, + ) + .unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("xml:lang=\"und\"")); + assert!(opf.contains("notes&edition")); + assert!(opf.contains("A & B")); + assert!(opf.contains("media-type=\"image/png\"")); +} + +/// File › File Info's Document Title and Author name the EPUB and the HTML page; the export +/// options' title and author replace them. +#[test] +fn file_info_titles_the_epub_and_the_html_page() { + let mut d = Document::new(&NewDocument { title: "flyer-v3".into(), ..Default::default() }); + d.file_info.document_title = "Spring & Summer".into(); + d.file_info.author = "Ana Ruiz".into(); + let opf = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + opf + }; + let o = opf(&d, &EpubOptions::default()); + assert!(o.contains("Spring & Summer") && o.contains("Ana Ruiz"), "{o}"); + let o = opf(&d, &EpubOptions { title: Some("Proof".into()), author: Some("Studio".into()), ..Default::default() }); + assert!(o.contains("Proof") && o.contains("Studio"), "{o}"); + let html = export_html(&d, &HtmlOptions::default()); + assert!(html.contains("Spring & Summer")); + d.file_info = Default::default(); + let o = opf(&d, &EpubOptions::default()); + assert!(o.contains("flyer-v3") && !o.contains(""), "{o}"); +} + +/// "Body¹ text", a 2×2 table, then "Closing" with an endnote. +fn tables_and_notes() -> Document { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert_note(4, "Foot note text", ParaFormat::default()); + let mut t = Table::new(1, 2, 2, 0, 0, 200.0); + for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] { + t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default()); + } + let end = st.len(); + st.insert_table(end, t); + let end = st.len(); + st.insert(end, "Closing"); + let end = st.len(); + st.insert_endnote(end, "End note text", ParaFormat::default()); + d +} + +#[test] +fn tables_footnotes_and_endnotes_are_exported() { + let d = tables_and_notes(); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let (t0, t1) = (html.find("").unwrap(), html.find("
    ").unwrap()); + for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] { + let at = html.find(&format!("

    {cell}

    \n")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}")); + assert!(t0 < at && at < t1); + } + assert_eq!(html.matches("").count(), 2); + + let noteref = "Body
    1 text"; + assert!(html.contains(noteref), "footnote reference:\n{html}"); + let aside = html.find("").unwrap(); + assert!(html[aside..aside_end].contains("1 Foot note text")); + assert!(aside < t0, "the footnote follows its paragraph"); + + assert!(html.contains("Closing1")); + let section = html.find("
    ").unwrap(); + assert!(section > html.find("Closing").unwrap()); + assert!(html[section..].contains("
  • ")); + assert!(html[section..].contains("1 End note text")); + let mut nav = String::new(); + z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap(); + assert!(nav.contains("href=\"content.xhtml#endnotes\"")); + + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("