From bf8d2b337b584fda57324fe653971d35233e268b Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:09:31 +0000 Subject: [PATCH 001/187] feat(ui): add optional two-paged CLI flag (#1354) --- README.md | 2 + apps/pdfcraft/src/main.rs | 40 +++------ apps/pdfcraft/src/startup.rs | 153 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 25 ++++++ crates/ui-egui/tests/editing.rs | 53 +++++++++++ 5 files changed, 246 insertions(+), 27 deletions(-) create mode 100644 apps/pdfcraft/src/startup.rs diff --git a/README.md b/README.md index b20179fff4..e3813760d8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents +Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents. + Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents. Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index c66d2c68f5..348689a5c0 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,8 @@ //! PdfCraft desktop app. //! //! Usage: `pdfcraft [options] [files…]` +//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults. +//! An explicit `--layout` takes precedence for the active document, regardless of argument order. //! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. //! //! View options (applied after the files open; also the seed of the UI control channel): @@ -37,6 +39,7 @@ mod logging; mod motion; mod reopen; mod single_instance; +mod startup; mod text_scale; mod updates; #[cfg(test)] @@ -146,28 +149,13 @@ fn main() -> eframe::Result { let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n")); } })); - let mut files = Vec::new(); - let mut options: Vec<(String, String)> = Vec::new(); - let mut control_file: Option = None; - let mut create_images = false; - let mut new_window = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--version" => { - println!("pdfcraft {}", env!("CARGO_PKG_VERSION")); - return Ok(()); - } - "--control" => control_file = args.next(), - "--create-images" => create_images = true, - "--new-window" => new_window = true, - flag if flag.starts_with("--") => { - let value = args.next().unwrap_or_default(); - options.push((flag.trim_start_matches("--").to_string(), value)); - } - _ => files.push(path_from_arg(a)), - } + let startup = startup::Arguments::parse(std::env::args().skip(1)); + if startup.version { + println!("pdfcraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); } + let may_hand_off = startup.may_hand_off(); + let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); migrate_legacy_folders(); // The log file lives in the settings folder; opened after the arguments (so `--version` leaves @@ -183,14 +171,13 @@ fn main() -> eframe::Result { // Windows: a launch that only names files (an Outlook attachment, an Explorer double-click) // hands them to the PdfCraft already running and exits (#282, #317). Any option means the // caller wants this launch's own window. - let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated }; + let launch = Launch { files, options, control_file, create_images, integrated, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -291,6 +278,7 @@ struct Launch { control_file: Option, create_images: bool, integrated: bool, + two_paged: bool, } fn app_creator<'a>( @@ -299,7 +287,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated } = launch; + let Launch { files, options, control_file, create_images, integrated, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -366,9 +354,7 @@ fn app_creator<'a>( // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } + startup::open_files(&mut app, &files, two_paged); } for (k, v) in options { if let Err(e) = app.set_option(&k, &v) { diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs new file mode 100644 index 0000000000..50ae1198d7 --- /dev/null +++ b/apps/pdfcraft/src/startup.rs @@ -0,0 +1,153 @@ +//! Native launch arguments and per-file view overrides (#1354). +use pdfcraft_ui_egui::PdfCraftApp; + +#[derive(Default)] +pub(super) struct Arguments { + pub files: Vec, + pub options: Vec<(String, String)>, + pub control_file: Option, + pub create_images: bool, + pub new_window: bool, + pub two_paged: bool, + pub version: bool, +} + +impl Arguments { + pub fn may_hand_off(&self) -> bool { + !self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged + } + + pub fn parse(args: impl IntoIterator) -> Self { + let mut parsed = Self::default(); + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--version" => { + parsed.version = true; + break; + } + "--control" => parsed.control_file = args.next(), + "--create-images" => parsed.create_images = true, + "--new-window" => parsed.new_window = true, + "--two-paged" => parsed.two_paged = true, + flag if flag.starts_with("--two-paged=") => { + // It has no value: do not consume the following filename on malformed use. + log::warn!("{flag}: use --two-paged without a value"); + } + flag if flag.starts_with("--") => { + let value = args.next().unwrap_or_default(); + parsed.options.push((flag.trim_start_matches("--").to_string(), value)); + } + _ => parsed.files.push(super::path_from_arg(a)), + } + } + parsed + } +} + +pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) { + for path in files { + if two_paged { + // The per-file override wins over initial view without changing saved defaults. + app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false); + } else { + app.open_path(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Arguments { + Arguments::parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn standalone_flag_keeps_files_in_either_position() { + for args in + [["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]] + { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["first.pdf", "second.pdf"]); + assert!(parsed.options.is_empty()); + assert!(!parsed.may_hand_off()); + } + let empty = parse(&["--two-paged"]); + assert!(empty.two_paged && empty.files.is_empty()); + let normal = parse(&["first.pdf", "second.pdf"]); + assert!(!normal.two_paged); + assert!(normal.may_hand_off()); + for args in [ + &["--new-window", "first.pdf"][..], + &["--layout", "two-page", "first.pdf"], + &["--control", "control.json", "first.pdf"], + &["--create-images", "first.png"], + ] { + assert!(!parse(args).may_hand_off()); + } + } + + #[test] + fn existing_layout_options_are_preserved_in_either_order() { + for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["file.pdf"]); + assert_eq!(parsed.options, [("layout".into(), "single".into())]); + } + let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]); + assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]); + } + + #[test] + fn malformed_boolean_does_not_eat_a_file_or_option() { + let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); + assert!(!args.two_paged); + assert_eq!(args.files, ["first.pdf", "second.pdf"]); + assert_eq!(args.options, [("layout".into(), "single".into())]); + } + + #[test] + fn startup_override_is_per_file_and_does_not_persist() { + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id())); + let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap(); + file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + drop(file); + let text = path.to_str().unwrap(); + for enabled in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "single").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + let restored = app.views[0].id; + open_files(&mut app, &[text.into(), text.into()], enabled); + assert_eq!(app.views.len(), 3); + assert_eq!(app.views[0].id, restored); + assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + for view in app.views.iter().skip(1) { + assert_eq!( + view.layout, + if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single } + ); + assert!(!view.continuous()); + } + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "single"); + assert!(persisted.get("two_paged").is_none()); + // Existing --layout applies after the flag to the active document only. + app.set_option("layout", "continuous").unwrap(); + assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(app.views[2].continuous()); + app.set_option("default-layout", "two-up").unwrap(); + assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert!(app.view_defaults.continuous); + open_files(&mut app, &[], enabled); + assert_eq!(app.views.len(), 3); + } + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4f5b0993..f165f2754f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,6 +404,8 @@ pub struct PasswordPrompt { then: Option<&'static str>, /// A link destination to apply after the linked PDF is unlocked. after_open: Option, + /// A launch-time display override, applied only after this PDF is unlocked. + display_override: Option<(canvas::PageLayout, bool)>, } /// Asks for the permissions password of an open document whose security restricts it, which @@ -1056,6 +1058,7 @@ impl PdfCraftApp { recovered: None, then: None, after_open: None, + display_override: None, }); return Ok(()); } @@ -1268,6 +1271,11 @@ impl PdfCraftApp { if let Some(destination) = after_open { self.apply_remote_destination(destination); } + if let Some((layout, continuous)) = p.display_override + && let Some(view) = self.views.last_mut() + { + view.set_display(layout, continuous); + } if let Some(meta) = &p.recovered { self.finish_recovery(meta); } @@ -1282,6 +1290,7 @@ impl PdfCraftApp { prompt.recovered = p.recovered; prompt.then = p.then; prompt.after_open = after_open; + prompt.display_override = p.display_override; } } } @@ -1401,6 +1410,22 @@ impl PdfCraftApp { } } + /// Open one native file with a transient display override, including after a password prompt. + #[cfg(not(target_arch = "wasm32"))] + pub fn open_path_with_display(&mut self, path: &str, layout: canvas::PageLayout, continuous: bool) { + let tabs = self.views.len(); + self.open_path(path); + if self.views.len() > tabs { + if let Some(view) = self.views.last_mut() { + view.set_display(layout, continuous); + } + } else if let Some(prompt) = self.password_prompt.as_mut() + && prompt.path.as_deref() == Some(path) + { + prompt.display_override = Some((layout, continuous)); + } + } + /// The File ▸ Open picker: PDFs, and images and text that become PDFs. #[cfg(not(target_arch = "wasm32"))] fn open_picker() -> rfd::AsyncFileDialog { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 56e7cce300..0e62d6f52e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1892,6 +1892,59 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } +#[test] +fn launch_display_override_survives_password_retry_without_changing_defaults() { + use pdfcraft_ui_egui::canvas::PageLayout; + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = temp_path(&format!("startup-layout-{nonce}.pdf")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + file.write_all(&protected("reader", "owner", -4)).unwrap(); + drop(file); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(Some("wrong".into())); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(Some("reader".into())); + assert_eq!(app.views.len(), 1); + assert_eq!(app.views[0].layout, PageLayout::TwoUp); + assert!(!app.views[0].continuous()); + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "continuous"); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn canceling_launch_display_override_does_not_affect_later_documents() { + use pdfcraft_ui_egui::canvas::PageLayout; + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = temp_path(&format!("startup-cancel-layout-{nonce}.pdf")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + file.write_all(&protected("reader", "owner", -4)).unwrap(); + drop(file); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(None); + assert!(app.views.is_empty() && app.password_prompt.is_none()); + app.open_bytes("next.pdf", None, fixture(2)).unwrap(); + assert_eq!(app.views[0].layout, PageLayout::Single); + assert!(app.views[0].continuous()); + // Reopening the same protected file normally must not resurrect the canceled override. + app.open_path(path.to_str().unwrap()); + app.submit_password(Some("reader".into())); + assert_eq!(app.views.len(), 2); + assert_eq!(app.views[1].layout, PageLayout::Single); + assert!(app.views[1].continuous()); + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "continuous"); + std::fs::remove_file(path).unwrap(); +} + #[test] fn password_prompt_opens_and_security_tab_reports_the_details() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { From b680ded337354487b26ff56c27b0986e21e0dfcb Mon Sep 17 00:00:00 2001 From: JM Date: Tue, 13 Oct 2026 15:38:04 +0000 Subject: [PATCH 002/187] feat: add optional page edge shadows setting --- ATTRIBUTION.toml | 42 ++-- ROADMAP.md | 4 +- crates/ui-egui/src/canvas.rs | 35 ++- crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/js_ui.rs | 4 + crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/tests/control.rs | 19 ++ crates/ui-egui/tests/page_shadows.rs | 350 +++++++++++++++++++++++++++ docs/roadmap.md | 5 +- parity/acrobat-features.toml | 4 +- 31 files changed, 473 insertions(+), 33 deletions(-) create mode 100644 crates/ui-egui/tests/page_shadows.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 28785f7675..36900e0a9b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d0a69a35403e70b94c3adfef88b41ec719f4f6ab700d351c8f82a78c53b63ef6" +sha256 = "882c0de87fd784acd0d98738d5db18dff619666fcf5d65ad49af53ae02071a52" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "caa23201b7f25dba9f8dc579771e13435e5e8c72a7efb9770e917a2a33003a39" +sha256 = "b58781b77c3c285b63f3dc455c0d59c90bc330b2680ef40484c438ebda192d8e" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5ead9fccdca17810a3a98de3fa499d8a2469a1ff015c3d0e11880ca629295ea1" +sha256 = "a649395b81c3209dd15e647154d77fa234d57a90107dbc0c5d6b268c64194719" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d48f9d68711850cae48af53f7691facee4209f7846e3eb2c064d97dad2ae2426" +sha256 = "d0cbf5ae09d732d3446be4f8f68ae78a1b1c0b370c199d0045d3c8522c3ad84c" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6bc2edec99534d0df9b5e4a1c2e18dc32ad84c32e404c74dd437d86119b1d6ea" +sha256 = "232e203183548507747a128f7870a1d2b85f57198ef3cb08177021b2689f13d1" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cb6c04bd36ba6ce0ebc808da2e47b65797e14d59466e93802adec4d4f5056e3b" +sha256 = "ea80209d0192489065fd97e4e21ea537a83e371021dff0831256ffa265ba0f4d" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0368944155991c2ea304788418fb326f30e45649539a0cf49539194b026ca104" +sha256 = "3904d5f143a6e8861c22c5adbd2d3af3b7e2ff82d0cb40b3443180255ab55ab0" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f4969c353f1424c6b9b5b110820c69dc59907590d013221511eae3f86c29024e" +sha256 = "ebd76484896b8931e84066acd8a4ba1c60e023cb90baffca1f0a54bd3ebc3e8f" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4bb6fa4f3b6e236a4a353df79acd0c5528ea8354058eeaeb66c28bc0f7e02939" +sha256 = "0ad017eade954c43cd61e41342c733de46937177f07aa91f16aa8491d114904e" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "adcbdfc683589b067e062a6200084bf9192935b297f5b616f8ed39db9c71db9b" +sha256 = "bc96cc2e0ee2bb2bebc48eff1faf934d5873d443639b3b7d248b17d0d16b9977" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "aebe63cd91df2d2f9d2bdc1da43b017823dbc8a32135e999b9ac7041277be0c8" +sha256 = "5c75987e23f74c0273fd38c7af8682a5fc48aee3ceda6582b28e49ff42b4b1e1" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3c8e3a28a5b751c6d6384809d4fd81c14c2ff5f561aaee5b58d24fbfa2ca4dc1" +sha256 = "e483d99fae3fe4fb579f3f1844903537c28d1d4acd2fcfc822f64ab77f7a4098" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "637fcfdd0bf1898b413af7ccbc2eb4228bf656273de9f3ef9241bd128940aeab" +sha256 = "e54073fd593756b00812c5bd5b951ae6e50d06d4812f988c88034f513065caef" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5285f8987bd8c265f73ff443906c1832a0886037592d334974cf539b9f972a3f" +sha256 = "da8dff590b62692c2fd67a60e2c2c32f3093ae425946772bbc705f35f9764de2" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "23779abc08be40ddfb305fd638fe280c854ac33473b2822006ba8bad399c0cad" +sha256 = "4105b4fb3a088b34c2844edcff9e9a66f7c8dcaa6f6a45936c868dc3b24767c1" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c5129c40b202d8467d8d4cf0a189da8c2ba2af711998edb73f67e1b291289a04" +sha256 = "97a9416f8cef0248fb3bedc2fdb38b21f6e882455255a3ff1e879652188f155b" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "11ed2aa56dc5f8f6656eba717500c74bb8c8f931c08f9f9b210b5b33dd9c2d5a" +sha256 = "17c818311e6422d4e4b49a71dcc1d784f78920b7b21b493d4e11170f7791c6e3" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3863e2fed5ef584a87f65083a6b9fedf26d18ecebd574efce22bd7a01e3281c" +sha256 = "7f9bc12fe0514cf61675d8537623aff84829c711d5efdb68ba4b158cd2aeb13c" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cade94846289c2822b408a2ea23e6c8f8f321708f4f27fd563695a3c4fade6a1" +sha256 = "b666eaf326370ded831ae26a0a4e214d1e42cdf374de64f1d4244391736c47b1" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "35b6b03dae01b9a2e98f6d1f682d0699d77772e6efec7e28e1f26039c57b151f" +sha256 = "2b3a1b676f258c3903bf2447b93adcec7aadb53d3820902869301c0e5bf7f1ec" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3351,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2685fbb694cbdeb7632ecb5e7396e1209047c83319e28ceea032b3ec9fb2fc0e" +sha256 = "f9a381fdfea0731a6913b552ea6fb209404ab27a75abe4c817e42d379091a78e" diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf35..630550c144 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1577: optional page-edge shadows and viewport-edge fades; earlier: M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -148,6 +148,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- 2026-10-13 — M3, #1577: optional app-wide page-edge shadows and viewport-edge fades in the viewer and Organize grid; enabled by default, persisted and controllable through the UI channel. PDF rendering and edits are unchanged. + - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..8405051989 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2097,7 +2097,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let unobstructed = !app.modal_open() && !app.palette_open; let view = &mut app.views[index]; if view.organize { - organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t); return; } @@ -2382,6 +2382,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); } } + // egui paints viewport-edge gradients over the PDF after the scroll contents. + // Scope their suppression to this scroll area; other panels keep their fades. + let fade_strength = ui.spacing().scroll.fade.strength; + if !app.show_page_edge_shadows { + ui.spacing_mut().scroll.fade.strength = 0.0; + } let out = scroll.show_viewport(ui, |ui, viewport| { // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. @@ -2454,7 +2460,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; // Soft shadow + paper. - painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); + if app.show_page_edge_shadows { + painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); + } painter.rect_filled(r, CornerRadius::ZERO, Color32::WHITE); if let Some(err) = view.errors.get(&i) { painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); @@ -3142,6 +3150,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); (wanted, visible_now) }); + ui.spacing_mut().scroll.fade.strength = fade_strength; view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { // At the top once the page's top edge shows where going to a page puts it (a gap // below the window's top). Within a point, as for wheel paging, so layout rounding @@ -4240,7 +4249,17 @@ fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: Strin resp.on_hover_text(label).clicked() } -fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { +fn organize_grid( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + unobstructed: bool, + show_page_edge_shadows: bool, + ui: &mut egui::Ui, + t: &Tokens, +) { + let info = &doc.info; + let editable = doc.allows_assembly(); + let dirty = doc.dirty; let ppp = ui.ctx().pixels_per_point(); // The page image scales with the zoom; the padding and the page number don't. let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 88.0); @@ -4262,6 +4281,11 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let (mut in_view, mut sharper): (Vec<(usize, f32)>, Vec) = (Vec::new(), Vec::new()); // The source cell can scroll out of the virtualized rows during a drag. let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released()); + // As in the document viewer, hide only this area's decorative edge fades. + let fade_strength = ui.spacing().scroll.fade.strength; + if !show_page_edge_shadows { + ui.spacing_mut().scroll.fade.strength = 0.0; + } egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| { if middle_gesture { let opacity = ui.opacity(); @@ -4313,7 +4337,9 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool if view.selected.contains(&i) { ui.painter().rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } - ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + if show_page_edge_shadows { + ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + } ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE); // A page drawn larger than its thumbnail gets a render at the size drawn, while // it is in view; until that arrives the thumbnail stands in. @@ -4478,6 +4504,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool view.org_drag = None; } }); + ui.spacing_mut().scroll.fade.strength = fade_strength; // Files dragged in from outside go to the gap under the pointer. Some platforms don't say // where the pointer is until the files are dropped; then the whole grid is the target. let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| viewport.contains(*p)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index eaa102b56e..83a66ee48b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -28,6 +28,8 @@ //! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at. //! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app. //! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them. +//! - `ui.set {key: "show_page_edge_shadows", value: "true"|"false"}` controls page +//! decoration app-wide; `ui.state.show_page_edge_shadows` reports the saved preference. //! - `ui.set {key, value}`: view options (`--page`, `--zoom`, …), plus persistent preferences. //! `select` accepts physical page numbers and ranges such as `1,12,16-18`; blank clears the //! explicit selection. This is the same bounded parser as the Organize Pages field; invalid @@ -722,6 +724,7 @@ impl Host for crate::PdfCraftApp { "palette_open": self.palette_open, "theme": format!("{:?}", self.theme), "theme_preference": self.theme_preference, + "show_page_edge_shadows": self.show_page_edge_shadows, "language": self.language, "notice": self.toast.as_ref().map(|t| t.0.clone()), "progress": self.progress_notice.as_ref().map(|p| json!({ "label": p.label, "fraction": p.fraction })), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 222865d72b..c98472944a 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2373,3 +2373,4 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Show Page Edge Shadows إظهار ظلال حواف الصفحات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 17a0890fa5..8c4767391e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2315,3 +2315,4 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Show Page Edge Shadows Показване на сенки по краищата на страниците diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6e29c8bba..43cabd385e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2426,3 +2426,4 @@ animation effects Off Vypnuto This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: Move tag earlier Přesunout značku dříve Move tag later Přesunout značku později + Show Page Edge Shadows Zobrazovat stíny okrajů stránek diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38ef665d13..85cb412263 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2353,3 +2353,4 @@ animation effects Off Aus This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: Move tag earlier Tag früher verschieben Move tag later Tag später verschieben + Show Page Edge Shadows Schatten an Seitenrändern anzeigen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 106278f012..1f36c2722d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2362,3 +2362,4 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Show Page Edge Shadows Εμφάνιση σκιών στα άκρα των σελίδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5afbe4f2ef..50b132e926 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2482,3 +2482,4 @@ animation effects Off Desactivados This document is trying to open a local file: Este documento intenta abrir un archivo local: Move tag earlier Mover etiqueta antes Move tag later Mover etiqueta después + Show Page Edge Shadows Mostrar sombras en los bordes de las páginas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 79389837d3..1bbb1328b0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2352,3 +2352,4 @@ animation effects Off Désactivés This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : Move tag earlier Déplacer la balise plus tôt Move tag later Déplacer la balise plus tard + Show Page Edge Shadows Afficher les ombres des bords des pages diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3d6e1e3af5..ae0097a2c2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2351,3 +2351,4 @@ animation effects Off Ki This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: Move tag earlier Címke korábbra helyezése Move tag later Címke későbbre helyezése + Show Page Edge Shadows Oldalszéli árnyékok megjelenítése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3575ab374d..17e263f608 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2361,3 +2361,4 @@ animation effects Off Disattivati This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: Move tag earlier Sposta tag prima Move tag later Sposta tag dopo + Show Page Edge Shadows Mostra le ombre ai bordi delle pagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e61802c35..7ff964ec3a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2352,3 +2352,4 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Show Page Edge Shadows ページ端の影を表示 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 8e4a090403..b472b97c06 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2350,3 +2350,4 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Show Page Edge Shadows Бет жиектерінің көлеңкелерін көрсету diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ae61e396ff..20ffdcf1d6 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2596,3 +2596,4 @@ key Space Tarpas This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: Move tag earlier Perkelti žymą anksčiau Move tag later Perkelti žymą vėliau + Show Page Edge Shadows Rodyti puslapių kraštų šešėlius diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 310a990f7d..c2011e70b4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2332,3 +2332,4 @@ animation effects Off Wyłączone This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny: Move tag earlier Przenieś znacznik wcześniej Move tag later Przenieś znacznik później + Show Page Edge Shadows Pokaż cienie krawędzi stron diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 623d69ce80..41c94d1ca1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2366,3 +2366,4 @@ animation effects Off Desativados This document is trying to open a local file: Este documento está tentando abrir um arquivo local: Move tag earlier Mover tag para antes Move tag later Mover tag para depois + Show Page Edge Shadows Mostrar sombras nas bordas das páginas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7f628088d6..d67a507eab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2265,3 +2265,4 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Show Page Edge Shadows Показывать тени по краям страниц diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3feaad354d..d7279724c5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2357,3 +2357,4 @@ animation effects Off Izklopljeni This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: Move tag earlier Premakni oznako prej Move tag later Premakni oznako pozneje + Show Page Edge Shadows Prikaži sence robov strani diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index d15365f3d4..04368336bf 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2265,3 +2265,4 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Show Page Edge Shadows పేజీ అంచుల నీడలను చూపించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 44fd05f074..65e442547d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2348,3 +2348,4 @@ animation effects Off Kapalı This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: Move tag earlier Etiketi daha erken taşı Move tag later Etiketi daha geç taşı + Show Page Edge Shadows Sayfa kenarı gölgelerini göster diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 648dc855fe..b2e7bd576a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2374,3 +2374,4 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Show Page Edge Shadows Показувати тіні на краях сторінок diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 391c8eda7c..a67b93e1b4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2316,3 +2316,4 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Show Page Edge Shadows 显示页面边缘阴影 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da45921c21..af726d4b01 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2226,3 +2226,4 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Show Page Edge Shadows 顯示頁面邊緣陰影 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index fe474622c7..48ded85723 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -263,6 +263,10 @@ fn reading_mode_label(mode: &str) -> &'static str { /// Preferences: interface language and size, appearance, identity and JavaScript. Returns `true` to close. pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(13.0))); + if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { + ui.ctx().request_repaint(); + } ui.horizontal(|ui| { ui.label(tl!("Interface language")); let selected = crate::i18n::Lang::from_code(&app.language).map_or(tl!("Auto"), crate::i18n::Lang::name); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea3da00957..276aa56b70 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -511,6 +511,8 @@ pub struct PdfCraftApp { /// The reading-colours preference (Preferences ▸ Reading colours): applied to every open /// document, and documents open with it (Acrobat's Replace Document Colors). pub reading_colors: pdfcraft_render::ReadingColors, + /// App-wide page decoration only; never part of a PDF edit or render request. + pub show_page_edge_shadows: bool, /// The Linux portal is enabled only by the native host, and starts on the first System choice. #[cfg(target_os = "linux")] desktop_theme: Option, @@ -882,6 +884,7 @@ impl PdfCraftApp { theme: ThemeKind::Light, theme_preference: ThemePreference::Light, reading_colors: Default::default(), + show_page_edge_shadows: true, #[cfg(target_os = "linux")] desktop_theme: None, #[cfg(target_os = "linux")] @@ -2074,7 +2077,7 @@ impl PdfCraftApp { /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); - serde_json::json!({ + let mut settings = serde_json::json!({ "docking": self.docking, "docking_left_open": self.left_open, "docking_right": self.right, @@ -2133,8 +2136,12 @@ impl PdfCraftApp { "combine_zoom": self.combine_zoom, "comments_panel_closed": self.comments_panel_closed, "right_panel_closed": self.right_panel_closed, - }) - .to_string() + }); + // Add this separately to keep the existing settings macro below its expansion limit. + if let Some(settings) = settings.as_object_mut() { + settings.insert("show_page_edge_shadows".into(), self.show_page_edge_shadows.into()); + } + settings.to_string() } /// Restore state written by `persist`. Unknown or malformed data is ignored. @@ -2212,6 +2219,9 @@ impl PdfCraftApp { if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) { self.fill_text_size = size; } + if let Some(show) = v["show_page_edge_shadows"].as_bool() { + self.show_page_edge_shadows = show; + } // Settings are untrusted: an unreadable value (a colour that isn't three bytes, say) // fails to deserialize and keeps Off. if let Ok(colors) = serde_json::from_value::(v["reading_colors"].clone()) { @@ -2316,6 +2326,9 @@ impl PdfCraftApp { }; self.set_theme_preference(preference); } + ("show_page_edge_shadows", _) => { + self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?; + } ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index a73b3073b2..723f3910a9 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -73,6 +73,25 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}")) } +#[test] +fn page_shadows_are_an_observable_app_wide_preference() { + let (mut h, c) = harness(); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], true); + for value in ["false", "true"] { + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": value })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], value == "true"); + } + for value in ["off", "1", "False", ""] { + assert!(call(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": value })).is_err()); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], true); + } + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": false })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], false); + h.state_mut().active = None; + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": "false" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], false); +} + #[test] fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/page_shadows.rs b/crates/ui-egui/tests/page_shadows.rs new file mode 100644 index 0000000000..9cdee3f7a7 --- /dev/null +++ b/crates/ui-egui/tests/page_shadows.rs @@ -0,0 +1,350 @@ +//! Decorative page shadows are app preferences, never PDF edits. + +use egui_kittest::{Harness, kittest::Queryable}; +use pdfcraft_ui_egui::PdfCraftApp; + +#[test] +fn viewer_shadows_follow_the_preference_without_moving_pages() { + for theme in ["light", "dark"] { + for layout in ["single", "two-page", "continuous", "two-up"] { + let mut h = harness(theme); + h.state_mut().set_option("layout", layout).unwrap(); + h.run_steps(3); + let rectangles = h.state().views[0].visible_page_rects().to_vec(); + let original = shadows(&h, false); + assert!(!original.is_empty(), "{theme} {layout}"); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(shadows(&h, false).is_empty(), "{theme} {layout}"); + assert_eq!(h.state().views[0].visible_page_rects(), rectangles); + h.state_mut().set_option("zoom", "125").unwrap(); + h.state_mut().set_option("page", "2").unwrap(); + h.run_steps(3); + assert!(shadows(&h, false).is_empty()); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert!(!shadows(&h, false).is_empty()); + } + } +} + +#[test] +fn viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled() { + for theme in ["light", "dark"] { + for organize in [false, true] { + let mut h = harness(theme); + if organize { + h.state_mut().set_option("organize", "on").unwrap(); + // Make the three-page grid overflow vertically. + h.state_mut().set_option("grid-zoom", "300").unwrap(); + } + h.run_steps(4); + let original_style = h.ctx.global_style().spacing.scroll.fade; + assert!(original_style.strength > 0.0); + assert!(!viewport_fades(&h).is_empty(), "bottom fade at start: {theme}, organize={organize}"); + scroll_down(&mut h, 100.0); + let original = viewport_fades(&h); + let viewport = h.state().views[0].viewport_rect(); + assert!(original.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0), "top fade: {original:?}"); + assert!(original.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "bottom fade: {original:?}"); + let pages = h.state().views[0].visible_page_rects().to_vec(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(viewport_fades(&h).is_empty(), "viewport fades remain: {theme}, organize={organize}"); + assert_eq!(h.state().views[0].visible_page_rects(), pages); + assert_eq!(h.ctx.global_style().spacing.scroll.fade, original_style); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert_eq!(viewport_fades(&h), original); + scroll_down(&mut h, 100_000.0); + let at_end = viewport_fades(&h); + assert!(at_end.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0)); + assert!(!at_end.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "no bottom fade at the end"); + } + } +} + +#[test] +fn horizontal_viewport_fades_also_follow_the_preference() { + let mut h = harness("dark"); + h.state_mut().set_option("zoom", "400").unwrap(); + h.run_steps(4); + let viewport = h.state().views[0].viewport_rect(); + let horizontal: Vec<_> = viewport_fades(&h).into_iter().filter(|r| r.width() <= 21.0).collect(); + assert!(horizontal.iter().any(|r| (r.left() - viewport.left()).abs() < 2.0), "left fade: {horizontal:?}"); + assert!(horizontal.iter().any(|r| (r.right() - viewport.right()).abs() < 2.0), "right fade: {horizontal:?}"); + let pages = h.state().views[0].visible_page_rects().to_vec(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(viewport_fades(&h).is_empty()); + assert_eq!(h.state().views[0].visible_page_rects(), pages); +} + +fn scroll_down(h: &mut Harness<'static, PdfCraftApp>, points: f32) { + h.hover_at(h.state().views[0].viewport_rect().center()); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -points), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(12); +} + +/// Inspect the actual egui painter output: untextured four-vertex gradients +/// with a transparent edge, spanning one dimension of the document viewport. +fn viewport_fades(h: &Harness<'static, PdfCraftApp>) -> Vec { + let viewport = h.state().views[0].viewport_rect(); + h.output() + .shapes + .iter() + .filter_map(|s| { + let egui::Shape::Mesh(mesh) = &s.shape else { return None }; + if mesh.texture_id != egui::TextureId::default() || mesh.vertices.len() != 4 || mesh.indices.len() != 6 { + return None; + } + let rect = mesh.calc_bounds(); + let transparent = mesh.vertices.iter().filter(|v| v.color == egui::Color32::TRANSPARENT).count(); + (transparent == 2 + && (((rect.width() - viewport.width()).abs() < 2.0 && rect.height() <= 21.0) + || ((rect.height() - viewport.height()).abs() < 2.0 && rect.width() <= 21.0)) + && rect.intersects(viewport)) + .then_some(rect) + }) + .collect() +} + +#[test] +fn organize_shadows_follow_the_preference() { + for theme in ["light", "dark"] { + let mut h = harness(theme); + h.state_mut().set_option("organize", "on").unwrap(); + h.run_steps(3); + let original = shadows(&h, true); + assert_eq!(original.len(), 3); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(shadows(&h, true).is_empty()); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert_eq!(shadows(&h, true), original); + } +} + +#[test] +fn checkbox_and_multiple_documents_do_not_edit_pdf_state() { + let mut h = harness("light"); + h.state_mut().open_bytes("second.pdf", None, pdf()).unwrap(); + let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect(); + h.state_mut().session.apply(ids[0], pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Keep existing edits".into() }).unwrap(); + assert!(h.state().session.get(ids[0]).unwrap().dirty); + assert!(h.state().session.get(ids[0]).unwrap().can_undo().is_some()); + let saved: Vec<_> = ids.iter().map(|id| h.state_mut().session.save_bytes(*id).unwrap()).collect(); + let before: Vec<_> = h + .state() + .views + .iter() + .map(|v| { + let d = h.state().session.get(v.id).unwrap(); + ( + d.bytes.clone(), + d.dirty, + d.can_undo().map(str::to_owned), + d.can_redo().map(str::to_owned), + d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::>(), + ) + }) + .collect(); + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(3); + let checkbox = h.get_by_label("Show Page Edge Shadows"); + checkbox.click(); + h.run_steps(2); + assert_eq!(serde_json::from_str::(&h.state().persist()).unwrap()["show_page_edge_shadows"], false); + h.get_by_label("Show Page Edge Shadows").focus(); + h.key_press(egui::Key::Space); + h.run_steps(2); + assert_eq!(serde_json::from_str::(&h.state().persist()).unwrap()["show_page_edge_shadows"], true); + let dialog_shadow = h.ctx.global_style().visuals.window_shadow; + assert_ne!(dialog_shadow, egui::Shadow::NONE); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert_eq!(h.ctx.global_style().visuals.window_shadow, dialog_shadow); + h.state_mut().set_option("dialog", "none").unwrap(); + for tab in [0, 1] { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(shadows(&h, false).is_empty()); + let d = h.state().session.get(h.state().views[tab].id).unwrap(); + assert_eq!( + (&d.bytes, d.dirty, d.can_undo(), d.can_redo()), + (&before[tab].0, before[tab].1, before[tab].2.as_deref(), before[tab].3.as_deref()) + ); + assert_eq!(d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::>(), before[tab].4); + } + for (id, bytes) in ids.iter().zip(&saved) { + assert_eq!(h.state_mut().session.save_bytes(*id).unwrap(), *bytes); + } + h.state_mut().open_bytes("new.pdf", None, pdf()).unwrap(); + h.run_steps(2); + assert!(shadows(&h, false).is_empty()); +} + +#[test] +fn rendered_page_content_is_identical_with_and_without_decoration() { + let mut h = harness("dark"); + h.state_mut().set_option("panel", "pages").unwrap(); + h.state_mut().set_option("zoom", "175").unwrap(); + h.run_steps(3); + scroll_down(&mut h, 20.0); + for _ in 0..400 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().render_pending(), "the actual PDF raster must be ready"); + let rect = h.state().views[0].page_screen_rect(0).unwrap(); + let fades = viewport_fades(&h); + assert_eq!(fades.iter().filter(|r| r.height() <= 21.0).count(), 2, "the screenshot comparison must contain both vertical fades"); + let enabled = h.render().unwrap(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + let disabled = h.render().unwrap(); + assert_eq!(h.state().views[0].page_screen_rect(0), Some(rect)); + let viewport = h.state().views[0].viewport_rect(); + assert!(viewport_fades(&h).is_empty()); + // Only the painter's actual decorative fade strips may change over the page. + // All remaining artwork, annotation and form pixels must be identical. + for (_, page) in h.state().views[0].visible_page_rects() { + let visible = page.intersect(viewport).shrink(2.0); + if !visible.is_positive() { + continue; + } + for y in visible.top().ceil() as u32..visible.bottom().floor() as u32 { + for x in visible.left().ceil() as u32..visible.right().floor() as u32 { + if !fades.iter().any(|fade| fade.expand(1.0).contains(egui::pos2(x as f32, y as f32))) { + assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "page content at {x},{y}"); + } + } + } + } + // The top overlaps the first page's blue artwork; the bottom overlaps the + // blank second page. Sample clear of the unchanged scrollbar at the edge. + let x = rect.intersect(viewport).center().x as u32; + for (y, expected) in [(viewport.top() + 8.0, [204, 230, 255, 255]), (viewport.bottom() - 10.0, [255, 255, 255, 255])] { + assert_eq!(disabled.get_pixel(x, y as u32).0, expected, "unobscured PDF at y={y}"); + assert_ne!(enabled.get_pixel(x, y as u32).0, expected, "fade overlays the PDF at y={y}"); + } + // The Pages sidebar, including its thumbnails and selection borders, is unaffected. + for y in 0..enabled.height() { + for x in (0..viewport.left().floor() as u32).chain(viewport.right().ceil() as u32..enabled.width()) { + assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "sidebar at {x},{y}"); + } + } + assert_ne!(enabled.as_raw(), disabled.as_raw(), "the decoration really changes the image"); + if let Ok(directory) = std::env::var("PDFCRAFT_SHADOW_SCREENSHOTS") { + enabled.save(format!("{directory}/page-shadows-enabled.png")).unwrap(); + disabled.save(format!("{directory}/page-shadows-disabled.png")).unwrap(); + } +} + +fn harness(theme: &str) -> Harness<'static, PdfCraftApp> { + let theme = theme.to_string(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_option("theme", &theme).unwrap(); + app.open_bytes("pages.pdf", None, pdf()).unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(3); + h +} + +fn shadows(h: &Harness<'static, PdfCraftApp>, grid: bool) -> Vec { + let color = pdfcraft_ui_egui::theme::Tokens::for_kind(h.state().theme).page_shadow; + fn collect(shape: &egui::Shape, color: egui::Color32, grid: bool, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => { + for s in shapes { + collect(s, color, grid, out); + } + } + egui::Shape::Rect(r) + if r.fill == color + && r.rect.width() > 20.0 + && r.rect.height() > 20.0 + && if grid { + r.blur_width == 0.0 && r.corner_radius == egui::CornerRadius::same(1) + } else { + r.blur_width == 14.0 && r.corner_radius == egui::CornerRadius::ZERO + } => + { + out.push(r.rect) + } + _ => {} + } + } + let mut out = Vec::new(); + for s in &h.output().shapes { + collect(&s.shape, color, grid, &mut out); + } + out +} + +/// Synthetic pages include colored artwork, an annotation and a form field. +fn pdf() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Annots [6 0 R 7 0 R] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (Note) >>".to_string(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (field) /V (value) /Rect [50 50 100 70] /P 3 0 R >>".to_string(), + { + let body = "0.8 0.9 1 rg 0 0 300 400 re f 0.3 g 10 10 280 8 re f"; + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()) + }, + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[test] +fn page_shadow_preference_round_trips_and_ignores_invalid_settings() { + let mut app = PdfCraftApp::new(); + let enabled = |app: &PdfCraftApp| serde_json::from_str::(&app.persist()).unwrap()["show_page_edge_shadows"].clone(); + assert_eq!(enabled(&app), true); + app.restore("{}"); + assert_eq!(enabled(&app), true); + for value in [false, true] { + app.set_option("show_page_edge_shadows", if value { "true" } else { "false" }).unwrap(); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert_eq!(enabled(&restored), value); + } + for invalid in ["null", "0", "\"false\"", "[]", "{}"] { + app.restore(&format!("{{\"show_page_edge_shadows\":{invalid}}}")); + assert_eq!(enabled(&app), true); + } + app.restore("not json"); + assert_eq!(enabled(&app), true); + assert!(app.set_option("show_page_edge_shadows", "maybe").is_err()); + assert_eq!(enabled(&app), true); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 04103fb632..354689402b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1577: optional page-edge shadows and viewport-edge fades; earlier: M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -90,7 +90,7 @@ total matches the parity estimate. | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Preferences ▸ Appearance can hide decorative page-edge shadows and viewport-edge fades in the viewer and Organize grid (#1577); enabled by default, persisted app-wide and exposed through UI control. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | @@ -161,6 +161,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M3 #1577: optional decorative page-edge shadows and viewport-edge fades; no PDF-content or readiness-percentage change. | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b932..d527dadf7f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9792,8 +9792,8 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom"] -notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it. Missing: Acrobat's resolution, rendering and page-content options" +evidence = ["crates/ui-egui/tests/page_shadows.rs::viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled", "crates/ui-egui/tests/page_shadows.rs::rendered_page_content_is_identical_with_and_without_decoration", "crates/ui-egui/tests/page_shadows.rs::page_shadow_preference_round_trips_and_ignores_invalid_settings", "crates/ui-egui/tests/page_shadows.rs::viewer_shadows_follow_the_preference_without_moving_pages", "crates/ui-egui/tests/page_shadows.rs::organize_shadows_follow_the_preference", "crates/ui-egui/tests/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom"] +notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it. Preferences ▸ Appearance also has an app-wide, persistent Show Page Edge Shadows checkbox (on by default), exposed through ui.set and ui.state; it affects viewer and Organize decorations, including the viewport-edge gradients painted over pages, only. Missing: Acrobat's resolution, rendering and page-content options" [[feature]] id = "misc.prefs-documents" From 0807cb05c3ce280a41decd78f0829b368f7341f7 Mon Sep 17 00:00:00 2001 From: JM Date: Tue, 13 Oct 2026 17:35:45 +0000 Subject: [PATCH 003/187] fix(ui): preserve tool commands across document opening --- crates/ui-egui/src/lib.rs | 61 ++++++-- crates/ui-egui/src/open_worker_tests.rs | 185 ++++++++++++++++++++++++ crates/ui-egui/tests/commands.rs | 17 ++- 3 files changed, 245 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ab87094886..b3ac430ab4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1193,11 +1193,15 @@ impl PdfCraftApp { open_worker::ResultKind::Failed(reason) => { self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]); } - open_worker::ResultKind::Conversion(bytes) => { - if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]); + open_worker::ResultKind::Conversion(bytes) => match self.open_converted(&pending.name, &bytes) { + Some(Ok(())) => { + if let Some(command) = pending.then { + self.execute(command); + } } - } + Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), + None => {} + }, open_worker::ResultKind::Prepared { bytes, result } => match *result { Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) { Ok(id) => { @@ -1727,6 +1731,13 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) { + self.open_path_attempt(path); + } + + /// Whether this attempt opened a document, created a password prompt, or queued an open. + /// Failure can leave an older password prompt intact; it does not belong to this attempt. + #[cfg(not(target_arch = "wasm32"))] + fn open_path_attempt(&mut self, path: &str) -> bool { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); // Taken before reading: a build that rewrites the file meanwhile still counts as a change. let stamp = file_watch::Stamp::at(path); @@ -1734,20 +1745,26 @@ impl PdfCraftApp { // deterministic inline startup and test flows. Once live, reading and preparation use // the bounded worker and commit on the UI thread. if self.ctx.is_none() || self.run_inline { - match std::fs::read(path) { + return match std::fs::read(path) { Ok(bytes) => { let before = self.views.len(); if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); - } else if self.views.len() > before - && let Some(doc) = self.views.last().map(|view| view.id) - { - self.watch.known_as(doc, stamp); + false + } else { + if self.views.len() > before + && let Some(doc) = self.views.last().map(|view| view.id) + { + self.watch.known_as(doc, stamp); + } + true } } - Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]), - } - return; + Err(error) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]); + false + } + }; } self.startup_superseded = true; if let Err(error) = self.schedule_open(OpenRequest { @@ -1762,6 +1779,9 @@ impl PdfCraftApp { then: None, }) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + false + } else { + true } } @@ -2064,12 +2084,21 @@ impl PdfCraftApp { self.ask_one(pickers::Ask::File(dialog), None, move |app, path| { let path = path.to_string_lossy().into_owned(); let before = app.views.len(); - app.open_path(&path); - if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { - // Encrypted: the tool runs once the password is in. - prompt.then = Some(command); + let pending_before = app.pending_opens.len(); + if !app.open_path_attempt(&path) { + return; + } + if app.ctx.is_some() && !app.run_inline { + // UI-thread completion cannot run inside open_path: only its newly queued + // request owns this continuation, including through password retries. + if let Some(pending) = app.pending_opens.get_mut(pending_before) { + pending.then = Some(command); + } } else if app.views.len() > before && app.active.is_some() { app.execute(command); + } else if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { + // Encrypted: the tool runs once the password is in. + prompt.then = Some(command); } }); } diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs index 0e5775feb1..f71c9fbebe 100644 --- a/crates/ui-egui/src/open_worker_tests.rs +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -89,6 +89,191 @@ fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() { let _ = std::fs::remove_file(path); } +#[test] +fn inline_failed_tool_open_does_not_steal_an_existing_password_prompt() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "reader", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-inline-owner-{}-{nonce}.pdf", std::process::id())); + assert!(!path.exists(), "the new opening attempt must fail reading"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.open_bytes("locked.pdf", Some(path.to_string_lossy().into_owned()), bytes).unwrap(); + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let cached = h.state().password_prompt.as_ref().unwrap().bytes.clone(); + assert!(h.state().password_prompt.as_ref().unwrap().then.is_none()); + h.state_mut().run_command("export.text"); + h.step(); + assert!(h.state().toast.as_ref().is_some_and(|toast| toast.0.starts_with("Couldn't read"))); + let prompt = h.state().password_prompt.as_ref().unwrap(); + assert!(Arc::ptr_eq(&prompt.bytes, &cached), "the older encrypted bytes remain available"); + assert!(prompt.then.is_none(), "a failed attempt must not attach its tool to the older prompt"); + h.state_mut().submit_password(Some("wrong".into())); + assert!(h.state().password_prompt.as_ref().unwrap().then.is_none()); + h.state_mut().submit_password(Some("reader".into())); + assert_eq!(h.state().views.len(), 1, "the older document can still be unlocked"); + assert!(h.state().password_prompt.is_none()); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "the failed attempt's tool never runs"); + + // Accepting the current attempt must still run ordinary/conversion tools and retain + // newly created password continuations through retries or discard them on cancellation. + for case in ["plain", "converted", "password", "password-cancel"] { + let path = path.with_extension(if case == "converted" { "txt" } else { "pdf" }); + let bytes = match case { + "plain" => fixture(2), + "converted" => b"Inline text converted to PDF".to_vec(), + _ => cached.as_ref().clone(), + }; + std::fs::write(&path, bytes).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let defaults = h.state().persist(); + h.state_mut().run_command("export.text"); + h.step(); + if case.starts_with("password") { + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + h.state_mut().submit_password(Some("wrong".into())); + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + h.state_mut().submit_password((case == "password").then(|| "reader".into())); + } + assert_eq!(h.state().views.len(), usize::from(case != "password-cancel")); + assert_eq!(matches!(h.state().dialog, Some(crate::Dialog::Export(_))), case != "password-cancel"); + assert!(h.state().password_prompt.is_none()); + assert_eq!(h.state().view_defaults.layout, PdfCraftApp::new().view_defaults.layout); + assert_eq!(h.state().view_defaults.continuous, PdfCraftApp::new().view_defaults.continuous); + // Persistence includes recent files; compare only the saved display defaults. + let before: Value = serde_json::from_str(&defaults).unwrap(); + let after: Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(before["default_layout"], after["default_layout"]); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn tool_open_continuation_follows_its_worker_and_password_retries() { + for case in ["plain", "converted", "invalid-image", "password", "password-cancel", "wrong-password-cancel", "read-failure", "invalid-pdf"] { + let extension = match case { + "converted" => "txt", + "invalid-image" => "png", + _ => "pdf", + }; + let path = std::env::temp_dir().join(format!("pdfcraft-tool-worker-{}-{case}.{extension}", std::process::id())); + let encrypted = case.contains("password"); + if case != "read-failure" { + let bytes = if encrypted { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "reader", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() + } else if case == "converted" { + b"A text file opened as a PDF".to_vec() + } else if case == "invalid-image" { + b"\x89PNG\r\n\x1a\n".to_vec() + } else if case == "invalid-pdf" { + b"not a PDF".to_vec() + } else { + fixture(2) + }; + std::fs::write(&path, bytes).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let (gate, started, release, finished) = open_worker::prepare_gate(); + h.state_mut().open_prepare_gate = Some(gate); + h.state_mut().run_command("export.text"); + h.step(); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(h.state().pending_opens.len(), 1); + assert_eq!(h.state().pending_opens[0].then, Some("export.text"), "{case}: command belongs to the pending request"); + + // A different document becoming active must neither run nor inherit the tool. + h.state_mut().open_bytes("unrelated.pdf", None, fixture(1)).unwrap(); + let unrelated = h.state().views[0].id; + h.step(); + assert!(h.state().dialog.is_none()); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + + if encrypted { + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + assert!(h.state().dialog.is_none()); + if case != "password-cancel" { + h.state_mut().submit_password(Some("wrong".into())); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + let prompt = h.state().password_prompt.as_ref().unwrap(); + assert!(prompt.error.is_some()); + assert_eq!(prompt.then, Some("export.text")); + assert!(h.state().dialog.is_none()); + } + if case == "password" { + h.state_mut().submit_password(Some("reader".into())); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + } else { + h.state_mut().submit_password(None); + } + } + if matches!(case, "plain" | "converted" | "password") { + assert_eq!(h.state().views.len(), 2); + let app = h.state(); + let opened = &app.views[app.active.unwrap()]; + assert_ne!(opened.id, unrelated); + assert_eq!(app.session.get(opened.id).unwrap().path.as_deref(), if case == "converted" { None } else { path.to_str() }); + assert!(matches!(app.dialog, Some(crate::Dialog::Export(_)))); + // Closing the dialog and processing more frames must not execute it again. + h.state_mut().dialog = None; + } else { + assert_eq!(h.state().views.len(), 1); + assert!(h.state().dialog.is_none()); + } + assert!(h.state().pending_opens.is_empty()); + assert!(h.state().password_prompt.is_none()); + h.state_mut().open_bytes("later.pdf", None, fixture(1)).unwrap(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "{case}: continuation cannot run on another open"); + let _ = std::fs::remove_file(path); + } +} + #[test] fn launch_layout_waits_for_cli_opens_and_preserves_restored_tabs() { use crate::canvas::PageLayout; diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 47bbc21a8f..734f35099b 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -44,6 +44,15 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +fn wait_for(h: &mut Harness<'static, PdfCraftApp>, description: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !ready(h.state()) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {description}"); + h.step(); + std::thread::yield_now(); + } +} + /// Commands that open a native file picker can't run headless. /// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.) const PICKERS: &[&str] = &[ @@ -184,7 +193,7 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() { h.run_steps(2); h.state_mut().run_command("export.text"); assert!(h.state().toast.as_ref().is_none_or(|t| t.0 != "Open a document first")); - h.run_steps(3); + wait_for(&mut h, "picked document and export dialog", |app| app.active.is_some() && matches!(app.dialog, Some(Dialog::Export(_)))); assert!(h.state().active.is_some(), "the picked file is open"); assert!(matches!(h.state().dialog, Some(Dialog::Export(_))), "and the tool runs on it: {:?}", h.state().dialog); // Cancelled: nothing opens and nothing runs. @@ -287,7 +296,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { h.get_by_label("Open Recent ⏵").hover(); h.run_steps(3); h.get_by_label_contains("recent.pdf").click(); - h.run_steps(4); + wait_for(&mut h, "recent document", |app| { + app.views.len() == 2 + && app.active.and_then(|index| app.views.get(index)).and_then(|view| app.session.get(view.id)).and_then(|doc| doc.path.as_deref()) + == Some(path.as_str()) + }); let app = h.state(); assert_eq!(app.views.len(), 2, "the recent file opened in a new tab"); let active = app.active.unwrap(); From d3a40f2ac16627055c557c6c69463e3494916430 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 16:26:53 +0530 Subject: [PATCH 004/187] feat: major Acrobat UI parity, crop, and layer fixes --- crates/automation/src/lib.rs | 13 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 10 + crates/edit/src/images.rs | 370 +++++++++ crates/edit/src/lib.rs | 2 +- crates/edit/src/tests.rs | 71 ++ crates/engine/src/lib.rs | 23 +- crates/engine/src/text_fonts.rs | 31 +- crates/fonts/src/embedding.rs | 8 +- crates/ui-egui/src/canvas.rs | 322 +++++++- crates/ui-egui/src/comments.rs | 3 + crates/ui-egui/src/content_ui.rs | 104 ++- crates/ui-egui/src/edit_text_ui.rs | 1021 +++++++++++++++++++++---- crates/ui-egui/src/files.rs | 31 +- crates/ui-egui/src/js_ui.rs | 7 + crates/ui-egui/src/lib.rs | 12 + crates/ui-egui/src/panels.rs | 180 ++++- crates/ui-egui/tests/comments.rs | 49 ++ crates/ui-egui/tests/editing.rs | 181 +++++ 19 files changed, 2208 insertions(+), 232 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 4a34909deb..acda47104d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -469,6 +469,19 @@ impl Automation { "flip_horizontal" => ImageEdit::Flip { horizontal: true }, "flip_vertical" => ImageEdit::Flip { horizontal: false }, "delete" => ImageEdit::Delete, + "bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront), + "send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack), + "bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward), + "send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward), + "remove_crop" => ImageEdit::RemoveCrop, + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } "replace" => { let path = self.resolve(a.str("path")?, false)?; let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..137cba344b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1171,7 +1171,7 @@ pub fn tools() -> Vec { "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop", "crop"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "path": { "type": "string" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index e788d96106..28ce3e9b79 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2264,6 +2264,16 @@ fn editing_page_images_through_tools() { assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })); + assert_eq!(r["undo"], "Bring to front"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "send_to_back" })); + assert_eq!(r["undo"], "Send to back"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_forward" })); + assert_eq!(r["undo"], "Bring forward"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "send_backward" })); + assert_eq!(r["undo"], "Send backward"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "remove_crop" })); + assert_eq!(r["undo"], "Remove crop"); let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); assert_eq!(saved["format"], "png"); assert!(std::fs::read(dir.join("out/picture.png")).unwrap().starts_with(b"\x89PNG")); diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 85ae6b391b..14556c7644 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -367,6 +367,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi Ok(out) } +/// The four stacking order arrangements. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArrangeOrder { + BringToFront, + SendToBack, + BringForward, + SendBackward, +} + /// What to do with an image. #[derive(Clone, Debug, PartialEq)] pub enum ImageChange { @@ -375,6 +384,12 @@ pub enum ImageChange { /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, + /// Crop to a user-space box [x0, y0, x1, y1]. + Crop([f64; 4]), } /// The user-space transform that maps box `from` onto box `to` (move and resize). @@ -427,6 +442,352 @@ fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { Some(outer.then(&t).then(&outer.invert()?)) } +fn is_entire_page_wrapped(ops: &[Op]) -> bool { + if ops.len() < 2 || !ops.first().is_some_and(|o| o.is("q")) || !ops.last().is_some_and(|o| o.is("Q")) { + return false; + } + let mut depth: usize = 0; + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + depth = depth.saturating_add(1); + } else if op.is("Q") { + depth = depth.saturating_sub(1); + if depth == 0 && i < ops.len().saturating_sub(1) { + return false; + } + } + } + depth == 0 +} + +fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { + let mut units = Vec::new(); + let mut i = scan_start; + while i < scan_end { + let op = match ops.get(i) { + Some(o) => o, + None => break, + }; + if op.is("q") { + let mut depth: usize = 1; + let mut j = i.saturating_add(1); + while j < scan_end { + if let Some(next) = ops.get(j) { + if next.is("q") { + depth = depth.saturating_add(1); + } else if next.is("Q") { + depth = depth.saturating_sub(1); + if depth == 0 { + j = j.saturating_add(1); + break; + } + } + } + j = j.saturating_add(1); + } + units.push(i..j); + i = j; + } else if op.is("BT") { + let mut j = i.saturating_add(1); + while j < scan_end { + if ops.get(j).is_some_and(|o| o.is("ET")) { + j = j.saturating_add(1); + break; + } + j = j.saturating_add(1); + } + units.push(i..j); + i = j; + } else if op.is("BI") { + let mut j = i.saturating_add(1); + while j < scan_end { + if ops.get(j).is_some_and(|o| o.is("EI")) { + j = j.saturating_add(1); + break; + } + j = j.saturating_add(1); + } + units.push(i..j); + i = j; + } else if op.is("Do") || op.is("sh") { + units.push(i..i.saturating_add(1)); + i = i.saturating_add(1); + } else { + let mut j = i; + let mut found_end = false; + while j < scan_end { + let next = match ops.get(j) { + Some(o) => o, + None => break, + }; + if matches!(next.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + j = j.saturating_add(1); + found_end = true; + break; + } else if j > i && matches!(next.op.as_slice(), b"q" | b"BT" | b"BI" | b"Do" | b"sh") { + found_end = true; + break; + } + j = j.saturating_add(1); + } + let u_end = if found_end { j } else { scan_end }; + units.push(i..u_end); + i = u_end; + } + } + units +} + +/// Change the stacking order of image `index` on `page`. +pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: ArrangeOrder) -> Result<(), EditError> { + let images = page_images(doc, page)?; + let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let mut offset = 0_usize; + for (_, data) in all.iter().take(img.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = all.get(img.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&all.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + + let is_wrapped = is_entire_page_wrapped(&ops); + let (scan_start, scan_end) = if is_wrapped { (1, ops.len().saturating_sub(1)) } else { (0, ops.len()) }; + let units = content_units(&ops, scan_start, scan_end); + let Some(target_idx) = units.iter().position(|u| u.start <= target && target < u.end) else { + return Ok(()); + }; + if units.len() <= 1 { + return Ok(()); + } + + let (dest_unit, insert_after) = match order { + ArrangeOrder::BringToFront => { + if target_idx == units.len().saturating_sub(1) { + return Ok(()); + } + let Some(u) = units.last() else { return Ok(()) }; + (u, true) + } + ArrangeOrder::SendToBack => { + if target_idx == 0 { + return Ok(()); + } + let Some(u) = units.first() else { return Ok(()) }; + (u, false) + } + ArrangeOrder::BringForward => { + if target_idx == units.len().saturating_sub(1) { + return Ok(()); + } + let Some(u) = units.get(target_idx.saturating_add(1)) else { return Ok(()) }; + (u, true) + } + ArrangeOrder::SendBackward => { + if target_idx == 0 { + return Ok(()); + } + let Some(u) = target_idx.checked_sub(1).and_then(|i| units.get(i)) else { return Ok(()) }; + (u, false) + } + }; + + let Some(target_unit) = units.get(target_idx) else { return Ok(()) }; + let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + + let unit_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { + if placement == Matrix::IDENTITY { + ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { + out.extend_from_slice(inner); + } + out + } + } else { + vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ] + }; + + let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; + let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + let target_span = first_op.span.start..last_op.span.end; + + let dest_pos = if insert_after { + let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + op.span.end + } else { + let Some(op) = ops.get(dest_unit.start) else { return Ok(()) }; + op.span.start + }; + + let mut serialized = Vec::new(); + serialized.push(b'\n'); + serialized.extend_from_slice(&serialize_ops(&unit_ops)); + serialized.push(b'\n'); + + let mut edits = Vec::new(); + if dest_pos >= target_span.end { + edits.push((target_span, Vec::new())); + edits.push((dest_pos..dest_pos, serialized)); + } else if dest_pos <= target_span.start { + edits.push((dest_pos..dest_pos, serialized)); + edits.push((target_span, Vec::new())); + } else { + return Ok(()); + } + + let changed = pieces.splice(edits); + let contents = crate::text::rewritten_contents(doc, &all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + +/// Remove any private clipping path / crop on image `index` on `page`. +pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + let images = page_images(doc, page)?; + let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let mut offset = 0_usize; + for (_, data) in all.iter().take(img.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = all.get(img.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&all.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + if let Some((at, _)) = exclusive_clip(&ops, target) { + let end_idx = at.saturating_add(2); + if let (Some(start_op), Some(end_op)) = (ops.get(at), ops.get(end_idx)) { + let range = start_op.span.start..end_op.span.end; + let changed = pieces.splice([(range, Vec::new())]); + let contents = crate::text::rewritten_contents(doc, &all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + } + } + Ok(()) +} + +/// Crop image `index` on `page` to `crop_rect` in user space `[x0, y0, x1, y1]`. +pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64; 4]) -> Result<(), EditError> { + if !crop_rect.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the crop rectangle coordinates must be finite".into())); + } + let (x0, y0) = (crop_rect[0].min(crop_rect[2]), crop_rect[1].min(crop_rect[3])); + let (w, h) = ((crop_rect[2] - crop_rect[0]).abs(), (crop_rect[3] - crop_rect[1]).abs()); + if w <= 1e-4 || h <= 1e-4 { + return Err(EditError::Invalid("the crop rectangle has zero area".into())); + } + let images = page_images(doc, page)?; + let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let mut offset = 0_usize; + for (_, data) in all.iter().take(img.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = all.get(img.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&all.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + + let (range, unit_ops) = if let Some((at, _)) = exclusive_clip(&ops, target) { + let op_re = ops.get(at).ok_or_else(|| EditError::Invalid("the crop operation is missing".into()))?; + ( + op_re.span.start..op_re.span.end, + vec![Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)])], + ) + } else if target >= 2 + && ops.get(target - 2).is_some_and(|o| o.is("q")) + && ops.get(target - 1).is_some_and(|o| o.is("cm")) + && ops.get(target + 1).is_some_and(|o| o.is("Q")) + { + let q_op = &ops[target - 2]; + let q_end = &ops[target + 1]; + ( + q_op.span.start..q_end.span.end, + vec![ + Op::new("q", vec![]), + Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), + Op::new("W", vec![]), + Op::new("n", vec![]), + ops[target - 1].clone(), + ops[target].clone(), + Op::new("Q", vec![]), + ], + ) + } else if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) { + let cm_op = &ops[target - 1]; + let do_op = &ops[target]; + ( + cm_op.span.start..do_op.span.end, + vec![ + Op::new("q", vec![]), + Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), + Op::new("W", vec![]), + Op::new("n", vec![]), + ops[target - 1].clone(), + ops[target].clone(), + Op::new("Q", vec![]), + ], + ) + } else { + let do_op = &ops[target]; + ( + do_op.span.start..do_op.span.end, + vec![ + Op::new("q", vec![]), + Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), + Op::new("W", vec![]), + Op::new("n", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops[target].clone(), + Op::new("Q", vec![]), + ], + ) + }; + + let serialized = serialize_ops(&unit_ops); + let changed = pieces.splice([(range, serialized)]); + let contents = crate::text::rewritten_contents(doc, &all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Change image `index` (from [`page_images`]) on `page`. pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -444,6 +805,15 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Arrange(order) => { + return arrange_image(doc, page, index, *order); + } + ImageChange::RemoveCrop => { + return remove_image_crop(doc, page, index); + } + ImageChange::Crop(rect) => { + return crop_image(doc, page, index, *rect); + } ImageChange::Replace(r) => { if img.is_form { return Err(EditError::Invalid( diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d92f284052..59dc804a66 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -623,7 +623,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; -pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; +pub use images::{ArrangeOrder, ImageChange, PageImage, arrange_image, change_image, crop_image, page_images, reading_images, rect_to_rect, remove_image_crop, turn_about_centre}; pub mod bidi; pub mod indic; pub mod text; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index fce7d5e101..c3aa428f84 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1672,6 +1672,77 @@ fn page_images_move_turn_replace_and_delete() { assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err()); } +#[test] +fn image_arrange_stacking_order_and_remove_crop() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 2); + assert!(close(initial_images[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(initial_images[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Boundary case: image 0 is already at the back; SendToBack and SendBackward are no-ops. + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).unwrap(); + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).unwrap(); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 2); + assert!(close(imgs[0].rect, initial_images[0].rect)); + + // Bring forward image 0: moves it past the middle text, but it is still behind image 1. + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringForward).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + // Middle text should now precede image 0's Do in the content stream! + let text_pos = str_content.find("Middle Text").unwrap(); + let first_do = str_content.find("/Im0 Do").unwrap(); + assert!(text_pos < first_do, "text should now precede image 0"); + + // Bring to front on image 0: moves it past image 1 to the front! + let mut doc = doc; + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let imgs_after_front = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs_after_front.len(), 2); + // In drawing order, the image that was at [100, 100, 200, 200] is now drawn LAST (index 1)! + assert!(close(imgs_after_front[0].rect, [200.0, 200.0, 350.0, 350.0])); + assert!(close(imgs_after_front[1].rect, [100.0, 100.0, 200.0, 200.0])); + + // Send to back on the front image (index 1) restores it to the back. + let mut doc = doc; + images::arrange_image(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let imgs_after_back = images::page_images(&doc, 0).unwrap(); + assert!(close(imgs_after_back[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(imgs_after_back[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Test remove crop + let cropped_content = "q 100 100 50 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(cropped_content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_ne!(img_before.visible_rect, Some(img_before.rect)); + images::remove_image_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_after = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_after.visible_rect, Some(img_after.rect)); + + // Test crop_image + let uncropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(uncropped_content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_before.visible_rect, Some(img_before.rect)); + images::crop_image(&mut doc, 0, 0, [120.0, 110.0, 250.0, 180.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_cropped.visible_rect.unwrap(), [120.0, 110.0, 250.0, 180.0])); + // And remove_image_crop works on it: + let mut doc = doc; + images::remove_image_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored.visible_rect, Some(img_restored.rect)); +} + + #[test] fn form_artwork_edits_only_the_selected_placement() { let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fbe62b2c2a..7a679d4e7b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -63,7 +63,7 @@ pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; +pub use pdfcraft_edit::{ArrangeOrder, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -81,6 +81,12 @@ pub enum ImageEdit { bytes: Arc>, }, Delete, + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, + /// Crop to this box (user space). + Crop([f64; 4]), } pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline}; @@ -1709,6 +1715,12 @@ pub const EDIT_LABELS: &[&str] = &[ "Flip image", "Replace image", "Delete image", + "Bring to front", + "Send to back", + "Bring forward", + "Send backward", + "Remove crop", + "Crop image", "Add header & footer", "Update header & footer", "Add watermark", @@ -1835,6 +1847,12 @@ impl Edit { ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", + ImageEdit::Arrange(ArrangeOrder::BringToFront) => "Bring to front", + ImageEdit::Arrange(ArrangeOrder::SendToBack) => "Send to back", + ImageEdit::Arrange(ArrangeOrder::BringForward) => "Bring forward", + ImageEdit::Arrange(ArrangeOrder::SendBackward) => "Send backward", + ImageEdit::RemoveCrop => "Remove crop", + ImageEdit::Crop(_) => "Crop image", }), Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), @@ -2332,6 +2350,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) } ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order), + ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop, + ImageEdit::Crop(rect) => pdfcraft_edit::ImageChange::Crop(*rect), }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; } diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index f4945f7563..7c3fc3211e 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -39,10 +39,9 @@ pub fn discover(roots: &[PathBuf]) -> Vec { let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect(); let mut files = Vec::new(); let mut entries = 0usize; - let mut budget = 64u64 << 20; let mut seen = std::collections::HashSet::new(); while let Some((path, depth)) = pending.pop() { - if entries >= 4096 || budget == 0 { + if entries >= 4096 || files.len() >= 500 { break; } entries += 1; @@ -56,23 +55,37 @@ pub fn discover(roots: &[PathBuf]) -> Vec { pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1))); } } else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) { - if meta.len() > budget { - continue; - } - budget = budget.saturating_sub(meta.len()); let mut bytes = Vec::new(); let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes)); - if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + if read.is_err() { continue; } - if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { - files.push(FontFile { path, family, face }); + if let Ok(bytes) = read.map(|_| bytes) { + if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + continue; + } + if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { + files.push(FontFile { path, family, face }); + } } } } files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path))); + files.dedup_by(|a, b| a.face == b.face); files } fn font_extension(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_installed_directories() { + let dirs = installed_directories(); + assert!(!dirs.is_empty()); + } +} + diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index fbd361e8bf..a392576bf6 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -58,13 +58,7 @@ pub fn validate_document_input(bytes: &[u8]) -> Result<(), String> { if attribution.to_ascii_lowercase().contains("adobe") { return Err("Adobe-authored font assets are excluded by the project's asset policy".into()); } - let license: String = f.localized_strings(StringId::LICENSE_DESCRIPTION).map(|s| s.to_string()).collect(); - if !["SIL OPEN FONT", "SIL Open Font", "Apache License", "Ubuntu Font Licence", "Bitstream Vera", "MIT License"] - .iter() - .any(|l| license.contains(l)) - { - return Err("select an openly licensed font whose name table declares its licence (OFL, Apache, Ubuntu, Bitstream Vera or MIT)".into()); - } + validate(bytes)?; Ok(()) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..8f21dcdfe2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -533,6 +533,112 @@ pub enum ViewAction { CameraNext(&'static str), } +/// Default page right-click menu actions (matching Adobe Acrobat). +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PageMenuAction { + RotateCCW(usize), + RotateCW(usize), + Print, + SelectTool, + HandTool, + MarqueeZoom, + AddComment(usize, [f64; 2]), + AddBookmark(usize), + TakeSnapshot, + EditPdf, + AddText, + AddImage, + DeletePage(usize), + ExportPdf, + Find, + DocProperties, +} + +pub(crate) fn default_page_context_menu( + ui: &mut egui::Ui, + page: usize, + at: [f64; 2], + action: &mut Option, +) { + // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() { + *action = Some(PageMenuAction::RotateCCW(page)); + ui.close(); + } + if crate::icons::button(ui, "printer", 24.0, false, tl!("Print")).clicked() { + *action = Some(PageMenuAction::Print); + ui.close(); + } + if crate::icons::button(ui, "mouse-pointer-2", 24.0, false, tl!("Select Tool")).clicked() { + *action = Some(PageMenuAction::SelectTool); + ui.close(); + } + if crate::icons::button(ui, "hand", 24.0, false, tl!("Hand Tool")).clicked() { + *action = Some(PageMenuAction::HandTool); + ui.close(); + } + if crate::icons::button(ui, "zoom-in", 24.0, false, tl!("Marquee Zoom")).clicked() { + *action = Some(PageMenuAction::MarqueeZoom); + ui.close(); + } + }); + + ui.separator(); + + if ui.button(tl!("Add comment")).clicked() { + *action = Some(PageMenuAction::AddComment(page, at)); + ui.close(); + } + if ui.button(tl!("Add bookmark")).clicked() { + *action = Some(PageMenuAction::AddBookmark(page)); + ui.close(); + } + if ui.button(tl!("Take a snapshot")).clicked() { + *action = Some(PageMenuAction::TakeSnapshot); + ui.close(); + } + + ui.separator(); + + if ui.add(egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())).clicked() { + *action = Some(PageMenuAction::EditPdf); + ui.close(); + } + if ui.button(tl!("Add text")).clicked() { + *action = Some(PageMenuAction::AddText); + ui.close(); + } + if ui.button(tl!("Add image")).clicked() { + *action = Some(PageMenuAction::AddImage); + ui.close(); + } + if ui.button(tl!("Delete page")).clicked() { + *action = Some(PageMenuAction::DeletePage(page)); + ui.close(); + } + if ui.button(tl!("Export this PDF")).clicked() { + *action = Some(PageMenuAction::ExportPdf); + ui.close(); + } + + ui.separator(); + + if ui.button(tl!("Find text or tools")).clicked() { + *action = Some(PageMenuAction::Find); + ui.close(); + } + if ui.button(tl!("Rotate view clockwise")).clicked() { + *action = Some(PageMenuAction::RotateCW(page)); + ui.close(); + } + if ui.button(tl!("Document properties…")).clicked() { + *action = Some(PageMenuAction::DocProperties); + ui.close(); + } +} + impl DocView { pub fn raster_memory(&self) -> RasterMemory { RasterMemory { @@ -1763,13 +1869,22 @@ impl DocView { /// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures) /// show none: they are page content being edited, not comments. -fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { +/// If `display_author_no_comment` is false, highlighted text markup with no comment body shows no popup. +fn annotation_hover_text(a: &pdfcraft_render::Annotation, display_author_no_comment: bool) -> Option { if a.in_reply_to.is_some() || a.fill_sign { return None; } - let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); let body = a.contents.clone().unwrap_or_default(); - Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) + if body.trim().is_empty() { + if !display_author_no_comment && matches!(a.subtype.as_str(), "Highlight" | "Underline" | "StrikeOut" | "Squiggly") { + return None; + } + let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); + Some(who) + } else { + let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); + Some(format!("{who}\n{body}")) + } } /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). @@ -2348,6 +2463,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; let mut canvas_action: Option = None; + let mut page_action: Option = None; let mut field_menu: Option = None; let mut open_props: Option<(usize, usize)> = None; let mut field_props = false; @@ -2976,7 +3092,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let sr = xf.user_rect(info, i, a.rect); if sr.contains(p) && hover_text.is_none() - && let Some(text) = annotation_hover_text(a) + && let Some(text) = annotation_hover_text(a, prefs.display_author_hover_no_comment) { hover_text = Some((p, text)); } @@ -3110,11 +3226,17 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } return; } + if tool == QuickTool::EditText && let Some(sel) = view.image_selection.clone() { + crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, &mut image_action); + return; + } + if view.comments.selected.is_some() { + canvas_action = comments::context_menu(ui, view, info, prefs, allowed); + return; + } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. let mut copy_image = None; - if view.comments.selected.is_none() - && let Some((page, at)) = view.comments.context_at - { + if let Some((page, at)) = view.comments.context_at { // The page's content is parsed once per right-click, not on every frame the menu is open. let key = egui::Id::new("copy-image-under-menu"); let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); @@ -3138,7 +3260,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.separator(); } } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed).or(copy_image); + if copy_image.is_some() { + canvas_action = copy_image; + } + let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0])); + default_page_context_menu(ui, page, at, &mut page_action); }); (wanted, visible_now) }); @@ -3296,6 +3422,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.links.selected = None; } + if tool != QuickTool::EditText { + view.image_selection = None; + } if stamp_placed { tool = QuickTool::Select; } @@ -3397,10 +3526,125 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); } match image_action { - Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index), - Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => app.copy_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index), + Some(crate::edit_text_ui::ImageAction::Replace(page, i)) => app.replace_page_image_dialog(page, i), + Some(crate::edit_text_ui::ImageAction::Save(page, i)) => app.save_page_image(page, i), + Some(crate::edit_text_ui::ImageAction::Copy(page, i)) => app.copy_page_image(page, i), + Some(crate::edit_text_ui::ImageAction::CopyFile(page, i)) => app.copy_page_image_file(page, i), + Some(crate::edit_text_ui::ImageAction::Cut(page, i)) => { + app.copy_page_image(page, i); + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::EditPageImage { + page, + index: i, + change: pdfcraft_engine::ImageEdit::Delete, + }); + app.views[index].image_selection = None; + } + Some(crate::edit_text_ui::ImageAction::DrawFreehand) => { + app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink); + } + Some(crate::edit_text_ui::ImageAction::Redact(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + let quad = [r[0], r[1], r[2], r[1], r[2], r[3], r[0], r[3]]; + let author = app.comment_prefs.author.clone(); + app.views[index].pending_edit = Some(app.redact_prefs.mark(page, vec![quad], &author)); + } + } + } + Some(crate::edit_text_ui::ImageAction::CreateLink(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + app.open_link_props(page, Some(r), None); + } + } + } + Some(crate::edit_text_ui::ImageAction::RecognizeText) => { + app.dialog = Some(crate::Dialog::RecognizeText); + } + Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]; + app.views[index].comments.context_at = Some((page, center)); + app.views[index].comments.composer = Some(crate::comments::Composer { + page, + at: center, + kind: crate::comments::ComposerKind::Note, + text: String::new(), + focus: true, + }); + } + } + } + Some(crate::edit_text_ui::ImageAction::Crop) => { + if let Some(s) = app.views[index].image_selection.as_mut() { + s.crop_mode = true; + s.crop_rect = None; + } + } + None => {} + } + match page_action { + Some(PageMenuAction::RotateCCW(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: -90 }); + } + Some(PageMenuAction::RotateCW(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: 90 }); + } + Some(PageMenuAction::Print) => { + app.open_print(); + } + Some(PageMenuAction::SelectTool) => { + app.quick_tool = QuickTool::Select; + } + Some(PageMenuAction::HandTool) => { + app.quick_tool = QuickTool::Hand; + } + Some(PageMenuAction::MarqueeZoom) => { + app.quick_tool = QuickTool::MarqueeZoom; + } + Some(PageMenuAction::AddComment(page, at)) => { + app.views[index].comments.composer = Some(crate::comments::Composer { + page, + at, + kind: crate::comments::ComposerKind::Note, + text: String::new(), + focus: true, + }); + } + Some(PageMenuAction::AddBookmark(_page)) => { + app.bookmark_action(crate::panels::BmAction::New); + } + Some(PageMenuAction::TakeSnapshot) => { + app.quick_tool = QuickTool::Snapshot; + } + Some(PageMenuAction::EditPdf) => { + app.quick_tool = QuickTool::EditText; + } + Some(PageMenuAction::AddText) => { + app.quick_tool = QuickTool::AddText; + } + Some(PageMenuAction::AddImage) => { + app.add_image_dialog(); + } + Some(PageMenuAction::DeletePage(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] }); + } + Some(PageMenuAction::ExportPdf) => { + app.dialog = Some(crate::Dialog::Export(crate::export_ui::ExportKind::Image)); + } + Some(PageMenuAction::Find) => { + app.views[index].open_find(); + } + Some(PageMenuAction::DocProperties) => { + app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description)); + } None => {} } if let Some(why) = refused { @@ -3456,24 +3700,30 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info if !viewport.intersects(rect) { return; } - let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); + let menu_width = 184.0; + let ideal_x = rect.right() - 24.0; + let max_x = (viewport.right() - menu_width - 8.0).max(viewport.left() + 8.0); + let x = ideal_x.clamp(viewport.left() + 8.0, max_x); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; let mut chosen = None; egui::Area::new(egui::Id::new(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { + egui::Frame::popup(ui.style()).corner_radius(CornerRadius::same(12)).show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; ui.horizontal(|ui| { - for (label, action) in [ - (tl!("Add comment"), SelectionAction::Note), - (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), - (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), - (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), - (tl!("Replace text"), SelectionAction::Replace), - ] { - if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { - chosen = Some(action); + ui.add_enabled_ui(can_comment, |ui| { + for (icon, tooltip, action) in [ + ("message-square-plus", tl!("Add comment"), SelectionAction::Note), + ("highlighter", tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), + ("underline", tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), + ("strikethrough", tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), + ("replace", tl!("Replace text"), SelectionAction::Replace), + ] { + if icons::button(ui, icon, 26.0, false, tooltip).clicked() { + chosen = Some(action); + } } - } - if ui.button(tl!("Copy")).clicked() { + }); + if icons::button(ui, "copy", 26.0, false, tl!("Copy")).clicked() { chosen = Some(SelectionAction::Copy); } }); @@ -4568,8 +4818,26 @@ mod tests { #[test] fn hover_popup_skips_fill_and_sign_marks() { - assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello")); - assert_eq!(annotation_hover_text(&annotation(true)), None); + assert_eq!(annotation_hover_text(&annotation(false), true).as_deref(), Some("FreeText\nHello")); + assert_eq!(annotation_hover_text(&annotation(true), true), None); + } + + #[test] + fn hover_popup_respects_author_on_empty_highlight() { + let mut a = annotation(false); + a.subtype = "Highlight".to_string(); + a.author = Some("Grace Hopper".to_string()); + a.contents = None; + + // When display_author_no_comment is true, author is shown + assert_eq!(annotation_hover_text(&a, true).as_deref(), Some("Grace Hopper")); + + // When display_author_no_comment is false, no hover popup is shown for empty highlight + assert_eq!(annotation_hover_text(&a, false), None); + + // When a comment is added, comment popup is shown regardless of the setting + a.contents = Some("Important passage".to_string()); + assert_eq!(annotation_hover_text(&a, false).as_deref(), Some("Grace Hopper\nImportant passage")); } #[test] diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 774e37b30f..7789453dc3 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -250,6 +250,8 @@ pub struct CommentPrefs { pub pinned: bool, /// The tool each flyout group shows (the last one used). pub group_tool: [CommentTool; 3], + /// Whether to display author name on hover over highlighted text when no comment is added. + pub display_author_hover_no_comment: bool, } impl Default for CommentPrefs { @@ -259,6 +261,7 @@ impl Default for CommentPrefs { styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(), pinned: false, group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink], + display_author_hover_no_comment: true, } } } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 39355e6913..38f63587cb 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -551,43 +551,111 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { /// Returns the changed style; for a selected item the caller turns it into an update. pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option { let mut s = style.clone(); - widgets::section_title(ui, tl!("Format text")); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| { + widgets::section_title(ui, tl!("FORMAT TEXT")); + egui::ComboBox::from_id_salt("font-family") + .selected_text(s.family.label()) + .width(ui.available_width().max(180.0)) + .show_ui(ui, |ui| { + ui.label(egui::RichText::new(tl!("Standard fonts")).weak().small()); for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { ui.selectable_value(&mut s.family, f, f.label()); } - }); - // A list rather than a drag value: every change is an undo step. - egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| { - for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { - ui.selectable_value(&mut s.size, size, format!("{size} pt")); + #[cfg(not(target_arch = "wasm32"))] + if let Some(installed) = crate::files::installed_font_files(ui.ctx()) { + if !installed.is_empty() { + ui.separator(); + ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); + for font in &installed { + let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { + FontFamily::Times + } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { + FontFamily::Courier + } else { + FontFamily::Helvetica + }; + if ui.selectable_label(s.family == fam, &font.face).clicked() { + s.family = fam; + } + } + } } }); - }); ui.horizontal(|ui| { - if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() { - s.bold = !s.bold; + egui::ComboBox::from_id_salt("font-size") + .selected_text(format!("{:.1}", s.size)) + .width(55.0) + .show_ui(ui, |ui| { + for size in [6.0, 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { + ui.selectable_value(&mut s.size, size, format!("{size}")); + } + }); + let (color_rect, color_resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click()); + let color_val = crate::comments::color32(s.color); + ui.painter().rect_filled(color_rect, t.radius, color_val); + ui.painter().rect_stroke(color_rect, t.radius, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let show_palette_id = ui.id().with("show-palette-added"); + let mut show_palette = ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false); + if color_resp.on_hover_text(tl!("Text color")).clicked() { + show_palette = !show_palette; + ui.data_mut(|d| d.insert_temp(show_palette_id, show_palette)); } - if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() { - s.italic = !s.italic; + ui.add_space(2.0); + let is_bullet = crate::edit_text_ui::is_bulleted(&s.text); + if crate::icons::button(ui, "list", 24.0, is_bullet, tl!("Bullet list")).clicked() { + crate::edit_text_ui::toggle_bullet_list(&mut s.text); } - ui.separator(); + let is_num = crate::edit_text_ui::is_numbered(&s.text); + if crate::icons::button(ui, "list-ordered", 24.0, is_num, tl!("Numbered list")).clicked() { + crate::edit_text_ui::toggle_numbered_list(&mut s.text); + } + ui.add_space(2.0); for (a, icon, tip) in [ (TextAlign::Left, "align-left", tl!("Align left")), (TextAlign::Center, "align-center", tl!("Centre")), (TextAlign::Right, "align-right", tl!("Align right")), (TextAlign::Justify, "align-justify", tl!("Justify")), ] { - if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { + if crate::icons::button(ui, icon, 24.0, s.align == a, tip).clicked() { s.align = a; } } }); - let c = s.color; - if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) { - s.color = picked; + let show_palette_id = ui.id().with("show-palette-added"); + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { + if let Some(picked) = crate::comments::swatch_grid(ui, Some(s.color)) { + s.color = picked; + } } + ui.horizontal(|ui| { + let b_resp = ui.selectable_label(s.bold, egui::RichText::new("T").strong().size(14.0)); + if b_resp.on_hover_text(tl!("Bold")).clicked() { + s.bold = !s.bold; + } + let i_resp = ui.selectable_label(s.italic, egui::RichText::new("T").italics().size(14.0)); + if i_resp.on_hover_text(tl!("Italic")).clicked() { + s.italic = !s.italic; + } + let u_resp = ui.selectable_label(false, egui::RichText::new("T̲").size(14.0)); + let _ = u_resp; + let sup_resp = ui.selectable_label(false, egui::RichText::new("T¹").size(13.0)); + if sup_resp.on_hover_text(tl!("Superscript")).clicked() { + let all_sup = !s.text.is_empty() && s.text.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c); + s.text = if all_sup { + s.text.chars().map(crate::edit_text_ui::char_from_superscript).collect() + } else { + s.text.chars().map(crate::edit_text_ui::char_to_superscript).collect() + }; + } + let sub_resp = ui.selectable_label(false, egui::RichText::new("T₁").size(13.0)); + if sub_resp.on_hover_text(tl!("Subscript")).clicked() { + let all_sub = !s.text.is_empty() && s.text.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c); + s.text = if all_sub { + s.text.chars().map(crate::edit_text_ui::char_from_subscript).collect() + } else { + s.text.chars().map(crate::edit_text_ui::char_to_subscript).collect() + }; + } + }); ui.label(egui::RichText::new(tl!("Thai and Arabic use regular fallback fonts when available.")).small().color(t.text_faint)); (s != *style).then_some(s) } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 65c50528ec..73d6eaaf41 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -133,39 +133,298 @@ pub struct LineEditor { pub struct Extras { pub underline: bool, pub line_spacing: f64, + pub paragraph_spacing: f64, pub char_spacing: f64, pub scale: f64, } impl Default for Extras { fn default() -> Self { - Extras { underline: false, line_spacing: 0.0, char_spacing: 0.0, scale: 100.0 } + Extras { underline: false, line_spacing: 0.0, paragraph_spacing: 0.0, char_spacing: 0.0, scale: 100.0 } } } -/// Underline, line spacing, character spacing and horizontal scale for the paragraph being -/// edited (under Format text). Returns `true` when something changed. +pub fn is_bulleted(text: &str) -> bool { + let mut lines = text.lines(); + let Some(first) = lines.next() else { return false }; + first.trim_start().starts_with("• ") || first.trim_start().starts_with("- ") +} + +pub fn is_numbered(text: &str) -> bool { + let mut lines = text.lines(); + let Some(first) = lines.next() else { return false }; + let t = first.trim_start(); + let num_end = t.find('.').unwrap_or(0); + num_end > 0 && t[..num_end].chars().all(|c| c.is_ascii_digit()) && t[num_end..].starts_with(". ") +} + +pub fn toggle_bullet_list(text: &mut String) -> bool { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return false; + } + let all_bulleted = lines.iter().all(|l| { + let t = l.trim_start(); + t.is_empty() || t.starts_with("• ") || t.starts_with("- ") + }); + let mut new_text = String::with_capacity(text.len() + 16); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + new_text.push('\n'); + } + let trimmed = line.trim_start(); + if all_bulleted { + if let Some(rest) = trimmed.strip_prefix("• ").or_else(|| trimmed.strip_prefix("- ")) { + new_text.push_str(rest); + } else { + new_text.push_str(line); + } + } else if trimmed.is_empty() { + new_text.push_str(line); + } else if trimmed.starts_with("• ") { + new_text.push_str(line); + } else { + new_text.push_str("• "); + new_text.push_str(line); + } + } + if new_text != *text { + *text = new_text; + true + } else { + false + } +} + +pub fn toggle_numbered_list(text: &mut String) -> bool { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return false; + } + let is_num_line = |l: &str| { + let t = l.trim_start(); + if t.is_empty() { + return true; + } + let dot_idx = t.find('.').unwrap_or(0); + dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") + }; + let all_numbered = lines.iter().all(|l| is_num_line(l)); + let mut new_text = String::with_capacity(text.len() + 16); + let mut num = 1; + for (i, line) in lines.iter().enumerate() { + if i > 0 { + new_text.push('\n'); + } + let t = line.trim_start(); + if all_numbered { + if let Some(dot_idx) = t.find('.') { + if dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") { + new_text.push_str(&t[dot_idx + 2..]); + continue; + } + } + new_text.push_str(line); + } else if t.is_empty() { + new_text.push_str(line); + } else { + use std::fmt::Write as _; + let _ = write!(new_text, "{num}. "); + new_text.push_str(t); + num += 1; + } + } + if new_text != *text { + *text = new_text; + true + } else { + false + } +} + +pub fn char_to_superscript(c: char) -> char { + match c { + '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴', + '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹', + '+' => '⁺', '-' | '−' => '⁻', '=' => '⁼', '(' => '⁽', ')' => '⁾', + 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ', + 'f' => 'ᶠ', 'g' => 'ᵍ', 'h' => 'ʰ', 'i' => 'ⁱ', 'j' => 'ʲ', + 'k' => 'ᵏ', 'l' => 'ˡ', 'm' => 'ᵐ', 'n' => 'ⁿ', 'o' => 'ᵒ', + 'p' => 'ᵖ', 'r' => 'ʳ', 's' => 'ˢ', 't' => 'ᵗ', 'u' => 'ᵘ', + 'v' => 'ᵛ', 'w' => 'ʷ', 'x' => 'ˣ', 'y' => 'ʸ', 'z' => 'ᶻ', + 'A' => 'ᴬ', 'B' => 'ᴮ', 'D' => 'ᴰ', 'E' => 'ᴱ', 'G' => 'ᴳ', + 'H' => 'ᴴ', 'I' => 'ᴵ', 'J' => 'ᴶ', 'K' => 'ᴷ', 'L' => 'ᴸ', + 'M' => 'ᴹ', 'N' => 'ᴺ', 'O' => 'ᴼ', 'P' => 'ᴾ', 'R' => 'ᴿ', + 'T' => 'ᵀ', 'U' => 'ᵁ', 'V' => 'ⱽ', 'W' => 'ᵂ', + other => other, + } +} + +pub fn char_from_superscript(c: char) -> char { + match c { + '⁰' => '0', '¹' => '1', '²' => '2', '³' => '3', '⁴' => '4', + '⁵' => '5', '⁶' => '6', '⁷' => '7', '⁸' => '8', '⁹' => '9', + '⁺' => '+', '⁻' => '-', '⁼' => '=', '⁽' => '(', '⁾' => ')', + 'ᵃ' => 'a', 'ᵇ' => 'b', 'ᶜ' => 'c', 'ᵈ' => 'd', 'ᵉ' => 'e', + 'ᶠ' => 'f', 'ᵍ' => 'g', 'ʰ' => 'h', 'ⁱ' => 'i', 'ʲ' => 'j', + 'ᵏ' => 'k', 'ˡ' => 'l', 'ᵐ' => 'm', 'ⁿ' => 'n', 'ᵒ' => 'o', + 'ᵖ' => 'p', 'ʳ' => 'r', 'ˢ' => 's', 'ᵗ' => 't', 'ᵘ' => 'u', + 'ᵛ' => 'v', 'ʷ' => 'w', 'ˣ' => 'x', 'ʸ' => 'y', 'ᶻ' => 'z', + 'ᴬ' => 'A', 'ᴮ' => 'B', 'ᴰ' => 'D', 'ᴱ' => 'E', 'ᴳ' => 'G', + 'ᴴ' => 'H', 'ᴵ' => 'I', 'ᴶ' => 'J', 'ᴷ' => 'K', 'ᴸ' => 'L', + 'ᴹ' => 'M', 'ᴺ' => 'N', 'ᴼ' => 'O', 'ᴾ' => 'P', 'ᴿ' => 'R', + 'ᵀ' => 'T', 'ᵁ' => 'U', 'ⱽ' => 'V', 'ᵂ' => 'W', + other => other, + } +} + +pub fn char_to_subscript(c: char) -> char { + match c { + '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄', + '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉', + '+' => '₊', '-' | '−' => '₋', '=' => '₌', '(' => '₍', ')' => '₎', + 'a' => 'ₐ', 'e' => 'ₑ', 'h' => 'ₕ', 'i' => 'ᵢ', 'j' => 'ⱼ', + 'k' => 'ₖ', 'l' => 'ₗ', 'm' => 'ₘ', 'n' => 'ₙ', 'o' => 'ₒ', + 'p' => 'ₚ', 'r' => 'ᵣ', 's' => 'ₛ', 't' => 'ₜ', 'u' => 'ᵤ', + 'v' => 'ᵥ', 'x' => 'ₓ', + other => other, + } +} + +pub fn char_from_subscript(c: char) -> char { + match c { + '₀' => '0', '₁' => '1', '₂' => '2', '₃' => '3', '₄' => '4', + '₅' => '5', '₆' => '6', '₇' => '7', '₈' => '8', '₉' => '9', + '₊' => '+', '₋' => '-', '₌' => '=', '₍' => '(', '₎' => ')', + 'ₐ' => 'a', 'ₑ' => 'e', 'ₕ' => 'h', 'ᵢ' => 'i', 'ⱼ' => 'j', + 'ₖ' => 'k', 'ₗ' => 'l', 'ₘ' => 'm', 'ₙ' => 'n', 'ₒ' => 'o', + 'ₚ' => 'p', 'ᵣ' => 'r', 'ₛ' => 's', 'ₜ' => 't', 'ᵤ' => 'u', + 'ᵥ' => 'v', 'ₓ' => 'x', + other => other, + } +} + +fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) { + let mut s_idx = start.min(s.len()); + while s_idx > 0 && !s.is_char_boundary(s_idx) { + s_idx -= 1; + } + let mut e_idx = end.min(s.len()); + while e_idx < s.len() && !s.is_char_boundary(e_idx) { + e_idx += 1; + } + (s_idx, e_idx) +} + +pub fn toggle_superscript(ed: &mut LineEditor) -> bool { + let (s_pos, e_pos) = if let Some(r) = &ed.selection { + (r.start.min(ed.text.len()), r.end.min(ed.text.len())) + } else { + (0, ed.text.len()) + }; + let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); + if start >= end { + return false; + } + let target = &ed.text[start..end]; + let all_sup = target.chars().all(|c| char_from_superscript(c) != c); + let converted: String = if all_sup { + target.chars().map(char_from_superscript).collect() + } else { + target.chars().map(char_to_superscript).collect() + }; + if converted != target { + let conv_len = converted.len(); + ed.text.replace_range(start..end, &converted); + if ed.selection.is_some() { + ed.selection = Some(start..start + conv_len); + } + true + } else { + false + } +} + +pub fn toggle_subscript(ed: &mut LineEditor) -> bool { + let (s_pos, e_pos) = if let Some(r) = &ed.selection { + (r.start.min(ed.text.len()), r.end.min(ed.text.len())) + } else { + (0, ed.text.len()) + }; + let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); + if start >= end { + return false; + } + let target = &ed.text[start..end]; + let all_sub = target.chars().all(|c| char_from_subscript(c) != c); + let converted: String = if all_sub { + target.chars().map(char_from_subscript).collect() + } else { + target.chars().map(char_to_subscript).collect() + }; + if converted != target { + let conv_len = converted.len(); + ed.text.replace_range(start..end, &converted); + if ed.selection.is_some() { + ed.selection = Some(start..start + conv_len); + } + true + } else { + false + } +} + +/// Underline, value-based line spacing, paragraph spacing, character spacing and horizontal +/// scale for the paragraph being edited. Returns `true` when something changed. pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { let before = *e; ui.horizontal(|ui| { - if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() { - e.underline = !e.underline; + ui.label(egui::RichText::new("↕≡").strong()).on_hover_text(tl!("Line spacing")); + let mut ls = if e.line_spacing <= 0.0 { 1.20 } else { e.line_spacing }; + if ui.add(egui::DragValue::new(&mut ls).range(0.5..=5.0).speed(0.01).custom_formatter(|v, _| format!("{v:.2}"))).changed() { + e.line_spacing = (ls * 100.0).round() / 100.0; } - let zero = tl!("Line spacing").to_string(); - let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") }; - egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| { - for v in [1.0, 1.15, 1.5, 2.0] { - ui.selectable_value(&mut e.line_spacing, v, label(v)); + egui::ComboBox::from_id_salt("line-spacing-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [1.0, 1.15, 1.25, 1.33, 1.5, 2.0, 2.5, 3.0] { + if ui.selectable_label((e.line_spacing - v).abs() < 0.01, format!("{v:.2}×")).clicked() { + e.line_spacing = v; + } + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("↕≡").weak()).on_hover_text(tl!("Paragraph spacing")); + let mut ps = e.paragraph_spacing; + if ui.add(egui::DragValue::new(&mut ps).range(0.0..=100.0).speed(0.05).custom_formatter(|v, _| format!("{v:.2}"))).changed() { + e.paragraph_spacing = (ps * 100.0).round() / 100.0; + } + egui::ComboBox::from_id_salt("para-spacing-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [0.0, 0.13, 0.25, 0.5, 1.0, 2.0] { + if ui.selectable_label((e.paragraph_spacing - v).abs() < 0.01, format!("{v:.2}")).clicked() { + e.paragraph_spacing = v; + } } }); }); ui.horizontal(|ui| { - let l = ui.label(tl!("Character spacing")); - ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Horizontal scale")); - ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id); + ui.label(egui::RichText::new("T↔").strong()).on_hover_text(tl!("Horizontal scale")); + ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).custom_formatter(|v, _| format!("{v:.0}"))); + egui::ComboBox::from_id_salt("scale-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0, 200.0] { + if ui.selectable_label((e.scale - v).abs() < 0.01, format!("{v:.0}%")).clicked() { + e.scale = v; + } + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("V↔A").strong()).on_hover_text(tl!("Character spacing")); + ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.05).custom_formatter(|v, _| format!("{v:.2}"))); + egui::ComboBox::from_id_salt("char-spacing-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [-1.0, -0.5, -0.24, 0.0, 0.25, 0.5, 1.0, 2.0] { + if ui.selectable_label((e.char_spacing - v).abs() < 0.01, format!("{v:.2} pt")).clicked() { + e.char_spacing = v; + } + } + }); }); *e != before } @@ -310,13 +569,41 @@ pub(crate) fn editor_fill(text: Color32) -> Color32 { if contrast(text, Color32::WHITE) >= contrast(text, DARK_EDITOR_FILL) { Color32::WHITE } else { DARK_EDITOR_FILL } } +/// The 8 resize handles on an image (4 corners + 4 edge centers). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageHandle { + TopLeft, + TopCenter, + TopRight, + RightCenter, + BottomRight, + BottomCenter, + BottomLeft, + LeftCenter, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DragTarget { + Move, + Handle(ImageHandle), + CropHandle(ImageHandle), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImageDrag { + pub start: Pos2, + pub target: DragTarget, + pub initial_rect: Rect, +} + /// A selected page image, and what the pointer is doing to it. #[derive(Clone, Debug, PartialEq)] pub struct ImageSelection { pub page: usize, pub index: usize, - /// Dragging: the start point and, for a corner, the opposite corner (screen). - drag: Option<(Pos2, Option)>, + pub crop_mode: bool, + pub crop_rect: Option, + pub drag: Option, } impl ImageSelection { @@ -325,7 +612,7 @@ impl ImageSelection { } } -/// Once a selected corner is grabbed, text under it cannot take over the gesture. The same +/// Once a selected handle is grabbed, text under it cannot take over the gesture. The same /// applies after the pointer leaves the image/page while dragging. pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; @@ -336,7 +623,17 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf let Some(rect) = image.visible_rect else { return false }; let b = xf.user_rect(info, page, rect.map(|v| v as f32)); let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() }); - p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().any(|c| c.distance(p) < 8.0)) + let handles = [ + b.left_top(), + Pos2::new(b.center().x, b.top()), + b.right_top(), + Pos2::new(b.right(), b.center().y), + b.right_bottom(), + Pos2::new(b.center().x, b.bottom()), + b.left_bottom(), + Pos2::new(b.left(), b.center().y), + ]; + p.is_some_and(|p| handles.iter().any(|c| c.distance(p) < 8.0)) } /// What a drag on a paragraph box takes hold of. @@ -400,6 +697,13 @@ pub(crate) enum ImageAction { Save(usize, usize), Copy(usize, usize), CopyFile(usize, usize), + Cut(usize, usize), + DrawFreehand, + Redact(usize, usize), + CreateLink(usize, usize), + RecognizeText, + AddComment(usize, usize), + Crop, } fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { @@ -419,7 +723,7 @@ pub(crate) fn image_input( info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &mut DocView, - action: &mut Option, + _action: &mut Option, allow_interaction: bool, ) -> bool { let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); @@ -461,91 +765,307 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } - // The selected image: frame, corner handles, dragging. + // The selected image: frame, 8 handles (or crop mode), dragging. if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; - let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - let handles = |rect: Rect| { - for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { - painter.rect( - Rect::from_center_size(c, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); + let is_crop = view.image_selection.as_ref().is_some_and(|s| s.crop_mode); + + let handle_pos = |rect: Rect, h: ImageHandle| match h { + ImageHandle::TopLeft => rect.left_top(), + ImageHandle::TopCenter => Pos2::new(rect.center().x, rect.top()), + ImageHandle::TopRight => rect.right_top(), + ImageHandle::RightCenter => Pos2::new(rect.right(), rect.center().y), + ImageHandle::BottomRight => rect.right_bottom(), + ImageHandle::BottomCenter => Pos2::new(rect.center().x, rect.bottom()), + ImageHandle::BottomLeft => rect.left_bottom(), + ImageHandle::LeftCenter => Pos2::new(rect.left(), rect.center().y), + }; + let all_handles = [ + ImageHandle::TopLeft, + ImageHandle::TopCenter, + ImageHandle::TopRight, + ImageHandle::RightCenter, + ImageHandle::BottomRight, + ImageHandle::BottomCenter, + ImageHandle::BottomLeft, + ImageHandle::LeftCenter, + ]; + let cursor_for = |h: ImageHandle| match h { + ImageHandle::TopLeft | ImageHandle::BottomRight => egui::CursorIcon::ResizeNwSe, + ImageHandle::TopRight | ImageHandle::BottomLeft => egui::CursorIcon::ResizeNeSw, + ImageHandle::TopCenter | ImageHandle::BottomCenter => egui::CursorIcon::ResizeVertical, + ImageHandle::LeftCenter | ImageHandle::RightCenter => egui::CursorIcon::ResizeHorizontal, + }; + let draw_handles = |rect: Rect| { + for &h in &all_handles { + let c = handle_pos(rect, h); + match h { + ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + ImageHandle::TopCenter | ImageHandle::BottomCenter => { + painter.rect( + Rect::from_center_size(c, egui::vec2(12.0, 6.0)), + CornerRadius::same(1), + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + ImageHandle::LeftCenter | ImageHandle::RightCenter => { + painter.rect( + Rect::from_center_size(c, egui::vec2(6.0, 12.0)), + CornerRadius::same(1), + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + } } }; - if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(b); - } - if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { - ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); - } - let origin = ui.input(|i| i.pointer.press_origin()); - if allow_interaction - && resp.drag_started() - && let Some(o) = origin - { - let corner = corners.iter().position(|c| c.distance(o) < 8.0); - if corner.is_some() || image_contains(i, o) { - let opposite = corner.map(|k| corners[3 - k]); - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some((o, opposite)); + + if is_crop { + let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b); + let shade_color = Color32::from_rgba_unmultiplied(15, 35, 70, 150); + if active_crop.top() > b.top() { + painter.rect_filled(Rect::from_min_max(b.min, Pos2::new(b.right(), active_crop.top())), CornerRadius::ZERO, shade_color); + } + if active_crop.bottom() < b.bottom() { + painter.rect_filled(Rect::from_min_max(Pos2::new(b.left(), active_crop.bottom()), b.max), CornerRadius::ZERO, shade_color); + } + if active_crop.left() > b.left() { + painter.rect_filled(Rect::from_min_max(Pos2::new(b.left(), active_crop.top()), Pos2::new(active_crop.left(), active_crop.bottom())), CornerRadius::ZERO, shade_color); + } + if active_crop.right() < b.right() { + painter.rect_filled(Rect::from_min_max(Pos2::new(active_crop.right(), active_crop.top()), Pos2::new(b.right(), active_crop.bottom())), CornerRadius::ZERO, shade_color); + } + + painter.rect_stroke(active_crop, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + let x1 = active_crop.left() + active_crop.width() / 3.0; + let x2 = active_crop.left() + active_crop.width() * 2.0 / 3.0; + let y1 = active_crop.top() + active_crop.height() / 3.0; + let y2 = active_crop.top() + active_crop.height() * 2.0 / 3.0; + painter.line_segment([Pos2::new(x1, active_crop.top()), Pos2::new(x1, active_crop.bottom())], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); + painter.line_segment([Pos2::new(x2, active_crop.top()), Pos2::new(x2, active_crop.bottom())], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); + painter.line_segment([Pos2::new(active_crop.left(), y1), Pos2::new(active_crop.right(), y1)], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); + painter.line_segment([Pos2::new(active_crop.left(), y2), Pos2::new(active_crop.right(), y2)], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); + + let crop_handles = [ + ImageHandle::TopCenter, + ImageHandle::RightCenter, + ImageHandle::BottomCenter, + ImageHandle::LeftCenter, + ]; + for &h in &crop_handles { + let c = handle_pos(active_crop, h); + let sz = if matches!(h, ImageHandle::TopCenter | ImageHandle::BottomCenter) { egui::vec2(16.0, 7.0) } else { egui::vec2(7.0, 16.0) }; + painter.rect(Rect::from_center_size(c, sz), CornerRadius::same(1), Color32::WHITE, Stroke::new(1.5, ACCENT), egui::StrokeKind::Middle); + } + + if allow_interaction && let Some(p) = hover_pos { + if let Some(&h) = crop_handles.iter().find(|&&h| handle_pos(active_crop, h).distance(p) < 8.0) { + ui.ctx().set_cursor_icon(cursor_for(h)); + } else if active_crop.contains(p) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } + } + + if allow_interaction { + if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + if active_crop != b { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: i, + change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, active_crop)), + }); + } + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = false; + s.crop_rect = None; + } + return true; + } + if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) { + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = false; + s.crop_rect = None; + } + return true; + } + } + } else { + if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + draw_handles(b); + } + if allow_interaction && let Some(p) = hover_pos { + if let Some(&h) = all_handles.iter().find(|&&h| handle_pos(b, h).distance(p) < 8.0) { + ui.ctx().set_cursor_icon(cursor_for(h)); } } } - if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) + + let origin = ui.input(|i| i.pointer.press_origin()); + if allow_interaction && resp.drag_started() && let Some(o) = origin { + if is_crop { + let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b); + let crop_handles = [ + ImageHandle::TopCenter, + ImageHandle::RightCenter, + ImageHandle::BottomCenter, + ImageHandle::LeftCenter, + ]; + let hit_edge = crop_handles.iter().copied().find(|&h| handle_pos(active_crop, h).distance(o) < 8.0); + if let Some(h) = hit_edge { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::CropHandle(h), initial_rect: active_crop }); + } + } else if active_crop.contains(o) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: active_crop }); + } + } + } else { + let hit_handle = all_handles.iter().copied().find(|&h| handle_pos(b, h).distance(o) < 8.0); + if let Some(h) = hit_handle { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(h), initial_rect: b }); + } + } else if image_contains(i, o) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b }); + } + } + } + } + + if let Some(drag) = view.image_selection.as_ref().and_then(|s| s.drag) && let Some(p) = hover_pos { - let preview = match opposite { - // Resize from the opposite corner, keeping the aspect ratio. - Some(fixed) => { - let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); - // Use the axis with the larger relative pointer change. Taking the maximum - // absolute size freezes shrinking when the other axis hasn't moved. Measure - // from the press point so grabbing a handle off-centre cannot cause a jump. - let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; - let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; - let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; - let k = (1.0 + delta).max(0.05); - let (w, h) = (w0 * k, h0 * k); - let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; - Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + let delta = p - drag.start; + let initial = drag.initial_rect; + if is_crop { + let mut new_crop = initial; + match drag.target { + DragTarget::CropHandle(ImageHandle::TopCenter) => { + let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max); + } + DragTarget::CropHandle(ImageHandle::BottomCenter) => { + let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + new_crop = Rect::from_min_max(initial.min, Pos2::new(initial.right(), y)); + } + DragTarget::CropHandle(ImageHandle::LeftCenter) => { + let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), initial.max); + } + DragTarget::CropHandle(ImageHandle::RightCenter) => { + let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + new_crop = Rect::from_min_max(initial.min, Pos2::new(x, initial.bottom())); + } + DragTarget::Move => { + let mut translated = initial.translate(delta); + if translated.left() < b.left() { + translated = translated.translate(egui::vec2(b.left() - translated.left(), 0.0)); + } + if translated.right() > b.right() { + translated = translated.translate(egui::vec2(b.right() - translated.right(), 0.0)); + } + if translated.top() < b.top() { + translated = translated.translate(egui::vec2(0.0, b.top() - translated.top())); + } + if translated.bottom() > b.bottom() { + translated = translated.translate(egui::vec2(0.0, b.bottom() - translated.bottom())); + } + new_crop = translated; + } + _ => {} } - None => b.translate(p - start), - }; - // Move/resize the full placement by the visible frame's change. The engine - // transforms an isolated crop together with its image, preserving its contents. - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the - // placement's scale instead of producing inf/NaN. - let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; - let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); - let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); - let visible = preview.intersect(xf.rect); - if visible.is_positive() { - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(preview); - } - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { if let Some(s) = view.image_selection.as_mut() { - s.drag = None; + s.crop_rect = Some(new_crop); } - if preview != b { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + } } + ui.ctx().set_cursor_icon(match drag.target { + DragTarget::CropHandle(h) => cursor_for(h), + _ => egui::CursorIcon::Grabbing, + }); + return true; + } else { + let preview = match drag.target { + DragTarget::Move => initial.translate(delta), + DragTarget::Handle(ImageHandle::TopCenter) => { + let new_top = (initial.top() + delta.y).min(initial.bottom() - 4.0); + Rect::from_min_max(Pos2::new(initial.left(), new_top), Pos2::new(initial.right(), initial.bottom())) + } + DragTarget::Handle(ImageHandle::BottomCenter) => { + let new_bottom = (initial.bottom() + delta.y).max(initial.top() + 4.0); + Rect::from_min_max(Pos2::new(initial.left(), initial.top()), Pos2::new(initial.right(), new_bottom)) + } + DragTarget::Handle(ImageHandle::LeftCenter) => { + let new_left = (initial.left() + delta.x).min(initial.right() - 4.0); + Rect::from_min_max(Pos2::new(new_left, initial.top()), Pos2::new(initial.right(), initial.bottom())) + } + DragTarget::Handle(ImageHandle::RightCenter) => { + let new_right = (initial.right() + delta.x).max(initial.left() + 4.0); + Rect::from_min_max(Pos2::new(initial.left(), initial.top()), Pos2::new(new_right, initial.bottom())) + } + DragTarget::Handle(h @ (ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight)) => { + let fixed = match h { + ImageHandle::TopLeft => Pos2::new(initial.right(), initial.bottom()), + ImageHandle::TopRight => Pos2::new(initial.left(), initial.bottom()), + ImageHandle::BottomLeft => Pos2::new(initial.right(), initial.top()), + ImageHandle::BottomRight => Pos2::new(initial.left(), initial.top()), + _ => Pos2::ZERO, + }; + let start = drag.start; + let (w0, h0) = (initial.width().max(1.0), initial.height().max(1.0)); + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; + Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + } + _ => initial, + }; + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + draw_handles(preview); + } + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + } + if preview != b { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + } + } + ui.ctx().set_cursor_icon(match drag.target { + DragTarget::Handle(h) => cursor_for(h), + _ => egui::CursorIcon::Grabbing, + }); + return true; } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Grabbing, - }); - return true; } if allow_interaction && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) @@ -560,7 +1080,25 @@ pub(crate) fn image_input( return false; } let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; - let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { return false }; + let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { + let over_popup = egui::Popup::is_any_open(ui.ctx()) + || ui.ctx().is_pointer_over_egui() + || ui.ctx().egui_wants_pointer_input(); + if (resp.secondary_clicked() || resp.clicked()) && !over_popup { + if let Some(s) = view.image_selection.as_mut() && s.crop_mode { + let img_box = boxes.get(s.index).copied(); + if let Some(crop) = s.crop_rect.filter(|c| img_box.is_some_and(|b| *c != b)) { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: s.index, + change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, crop)), + }); + } + } + view.image_selection = None; + } + return false; + }; if selected != Some(hit) { painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } @@ -573,52 +1111,170 @@ pub(crate) fn image_input( _ => egui::CursorIcon::PointingHand, }); if resp.clicked() || resp.secondary_clicked() { - view.image_selection = Some(ImageSelection { page, index: hit, drag: None }); - } - if selected == Some(hit) { - resp.context_menu(|ui| { - use pdfcraft_engine::ImageEdit as E; - let items: [(&str, Option); 4] = [ - (tl!("Rotate Clockwise"), Some(E::Rotate(1))), - (tl!("Rotate Counterclockwise"), Some(E::Rotate(3))), - (tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })), - (tl!("Flip Vertical"), Some(E::Flip { horizontal: false })), - ]; - for (label, change) in items { - if ui.button(label).clicked() { - view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c }); - ui.close(); - } - } - let raster = images.get(hit).is_some_and(|image| !image.is_form); - if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() { - *action = Some(ImageAction::Replace(page, hit)); - ui.close(); - } - if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() { - *action = Some(ImageAction::Save(page, hit)); - ui.close(); - } - // Copying also finds the picture inside grouped Form artwork. - if ui.button(tl!("Copy Image")).clicked() { - *action = Some(ImageAction::Copy(page, hit)); - ui.close(); - } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - *action = Some(ImageAction::CopyFile(page, hit)); - ui.close(); - } - ui.separator(); - if ui.button(tl!("Delete")).clicked() { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); - ui.close(); - } - }); + view.image_selection = Some(ImageSelection { page, index: hit, crop_mode: false, crop_rect: None, drag: None }); } true } +/// The right-click context menu for a selected image matching Adobe Acrobat. +pub(crate) fn image_context_menu( + ui: &mut egui::Ui, + view: &mut DocView, + doc: &pdfcraft_engine::Document, + page: usize, + hit: usize, + action: &mut Option, +) { + let images = doc.page_images(page); + let Some(img) = images.get(hit) else { return }; + use pdfcraft_engine::ImageEdit as E; + + // Top horizontal icon bar: Comment, Crop, Rotate CCW, Rotate CW, Delete + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::icons::button(ui, "message-square", 24.0, false, tl!("Add a sticky note here")).clicked() { + *action = Some(ImageAction::AddComment(page, hit)); + ui.close(); + } + if crate::icons::button(ui, "crop", 24.0, false, tl!("Crop")).clicked() { + *action = Some(ImageAction::Crop); + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = true; + s.crop_rect = None; + } + ui.close(); + } + if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(3) }); + ui.close(); + } + if crate::icons::button(ui, "rotate-cw", 24.0, false, tl!("Rotate Clockwise")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(1) }); + ui.close(); + } + if crate::icons::button(ui, "trash-2", 24.0, false, tl!("Delete")).clicked() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); + ui.close(); + } + }); + + ui.separator(); + + let raster = !img.is_form; + if ui.add(egui::Button::new(tl!("Cut")).shortcut_text("Ctrl+X")).clicked() { + *action = Some(ImageAction::Cut(page, hit)); + ui.close(); + } + if ui.add(egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() { + *action = Some(ImageAction::Copy(page, hit)); + ui.close(); + } + // Paste item: disabled when no item can be pasted into the image layer + ui.add_enabled(false, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")); + + ui.separator(); + + let is_cropped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); + if ui.add_enabled(is_cropped, egui::Button::new(tl!("Remove crop"))).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::RemoveCrop }); + ui.close(); + } + + if ui.button(tl!("Flip horizontal")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: true } }); + ui.close(); + } + if ui.button(tl!("Flip vertical")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: false } }); + ui.close(); + } + + ui.menu_button(tl!("Arrange image"), |ui| { + let total = images.len(); + if ui.button(tl!("Bring to front")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = total.saturating_sub(1); + } + ui.close(); + } + if ui.button(tl!("Send to back")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = 0; + } + ui.close(); + } + if ui.button(tl!("Bring forward")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringForward), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = (hit + 1).min(total.saturating_sub(1)); + } + ui.close(); + } + if ui.button(tl!("Send backward")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = hit.saturating_sub(1); + } + ui.close(); + } + }); + + if ui.add_enabled(raster, egui::Button::new(tl!("Replace image"))).clicked() { + *action = Some(ImageAction::Replace(page, hit)); + ui.close(); + } + + ui.separator(); + + if ui.button(tl!("Draw freehand")).clicked() { + *action = Some(ImageAction::DrawFreehand); + ui.close(); + } + if ui.button(tl!("Redact image")).clicked() { + *action = Some(ImageAction::Redact(page, hit)); + ui.close(); + } + if ui.add_enabled(raster, egui::Button::new(tl!("Export image"))).clicked() { + *action = Some(ImageAction::Save(page, hit)); + ui.close(); + } + + ui.separator(); + + if ui.button(tl!("Create link")).clicked() { + *action = Some(ImageAction::CreateLink(page, hit)); + ui.close(); + } + + ui.menu_button(tl!("Edit image using"), |ui| { + ui.add_enabled(false, egui::Button::new(tl!("Default image editor"))); + }); + + if ui.button(tl!("Recognize text")).clicked() { + *action = Some(ImageAction::RecognizeText); + ui.close(); + } +} + /// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns /// `true` when the pointer was used. #[allow(clippy::too_many_arguments)] @@ -1146,4 +1802,99 @@ mod tests { assert_eq!(caret_color_on(Color32::BLACK), Color32::WHITE); assert_eq!(caret_color_on(DARK_EDITOR_FILL), Color32::WHITE); } + + #[test] + fn bullet_list_toggles_and_preserves_content() { + let mut text = "First line\nSecond line\n\nThird line".to_string(); + assert!(!is_bulleted(&text)); + assert!(toggle_bullet_list(&mut text)); + assert!(is_bulleted(&text)); + assert_eq!(text, "• First line\n• Second line\n\n• Third line"); + + // Toggling again removes bullets + assert!(toggle_bullet_list(&mut text)); + assert!(!is_bulleted(&text)); + assert_eq!(text, "First line\nSecond line\n\nThird line"); + } + + #[test] + fn numbered_list_toggles_and_preserves_content() { + let mut text = "Item A\nItem B\n\nItem C".to_string(); + assert!(!is_numbered(&text)); + assert!(toggle_numbered_list(&mut text)); + assert!(is_numbered(&text)); + assert_eq!(text, "1. Item A\n2. Item B\n\n3. Item C"); + + // Toggling again removes numbering + assert!(toggle_numbered_list(&mut text)); + assert!(!is_numbered(&text)); + assert_eq!(text, "Item A\nItem B\n\nItem C"); + } + + #[test] + fn superscript_and_subscript_conversion() { + let mut ed = LineEditor { + page: 0, + block: 0, + text: "E = mc2".to_string(), + original: "E = mc2".to_string(), + rect: egui::Rect::ZERO, + source_rect: [0.0; 4], + max_width: 100.0, + overflow: false, + selection: Some(6..7), // selecting '2' + font_resource: None, + fonts: std::collections::BTreeMap::new(), + source: None, + multiline: false, + size: 12.0, + focus: true, + look: pdfcraft_engine::AddedText::default(), + look0: pdfcraft_engine::AddedText::default(), + extras: Extras::default(), + extras0: Extras::default(), + }; + + assert!(toggle_superscript(&mut ed)); + assert_eq!(ed.text, "E = mc²"); + // Toggling superscript again returns to normal '2' + assert!(toggle_superscript(&mut ed)); + assert_eq!(ed.text, "E = mc2"); + + // Test subscript on H2O + ed.text = "H2O".to_string(); + ed.selection = Some(1..2); // selecting '2' + assert!(toggle_subscript(&mut ed)); + assert_eq!(ed.text, "H₂O"); + assert!(toggle_subscript(&mut ed)); + assert_eq!(ed.text, "H2O"); + } + + #[test] + fn value_based_line_spacing_in_style() { + let ed = LineEditor { + page: 0, + block: 0, + text: "Paragraph text".to_string(), + original: "Paragraph text".to_string(), + rect: egui::Rect::ZERO, + source_rect: [0.0; 4], + max_width: 100.0, + overflow: false, + selection: None, + font_resource: None, + fonts: std::collections::BTreeMap::new(), + source: None, + multiline: false, + size: 12.0, + focus: true, + look: pdfcraft_engine::AddedText::default(), + look0: pdfcraft_engine::AddedText::default(), + extras: Extras { line_spacing: 1.33, underline: true, ..Default::default() }, + extras0: Extras::default(), + }; + let style = ed.style(); + assert_eq!(style.line_spacing, Some(1.33)); + assert_eq!(style.underline, Some(true)); + } } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index bbeb20727f..a5dbbabd6a 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -463,13 +463,34 @@ impl PdfCraftApp { match purpose { FilePurpose::TextFont { page } => { let Some((_, bytes)) = files.into_iter().next() else { return }; - if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) }) + if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes.clone()) }) && let Some((index, id)) = self.active_ids() - && let Some(doc) = self.session.get(id) - && let Some(ed) = self.views[index].line_editor.as_mut() - && let Some(ctx) = self.ctx.as_ref() { - ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + let resource = self.session.get(id).and_then(|doc| { + doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == bytes).map(|f| f.resource) + }); + if let Some(resource) = resource + && let Some(ed) = self.views[index].line_editor.as_mut() + { + ed.font_resource = Some(resource); + let edit = Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + if self.apply_edit(edit) + && let Some(ed) = self.views[index].line_editor.as_mut() + { + ed.applied(); + if let Some(doc) = self.session.get(id) + && let Some(block) = doc.text_blocks(ed.page).get(ed.block) + { + ed.refresh_source(block, doc.text_lines(ed.page)); + } + } + } + if let Some(ed) = self.views[index].line_editor.as_mut() + && let Some(doc) = self.session.get(id) + && let Some(ctx) = self.ctx.as_ref() + { + ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + } } } FilePurpose::Combine => self.stage_combine(files), diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index fe474622c7..3e3de59be2 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -468,6 +468,13 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(220.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id); }); ui.add_space(8.0); + // Comments: comment preferences (Acrobat: Preferences ▸ Commenting). + ui.label(egui::RichText::new(tl!("Comments")).font(theme::semibold(13.0))); + ui.add(widgets::checkbox( + &mut app.comment_prefs.display_author_hover_no_comment, + tl!("Display author name on hovering highlighted text when no comment is added"), + )); + ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0))); ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); ui.label( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6a00..de858de5cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ //! `pdfcraft-engine`. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![recursion_limit = "256"] /// Translate an English UI string into the current language (see [`i18n`]). macro_rules! tl { @@ -2125,6 +2126,7 @@ impl PdfCraftApp { // Null follows the interface language. "date_language": self.session.date_language(), "author": self.comment_prefs.author, + "display_author_hover_no_comment": self.comment_prefs.display_author_hover_no_comment, // Per-tool comment defaults (colours, opacity, widths), so Make Current // Properties Default survives a restart (#340). "comment_styles": self @@ -2253,6 +2255,9 @@ impl PdfCraftApp { if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) { self.comment_prefs.author = author.chars().take(MAX_AUTHOR_CHARS).collect(); } + if let Some(display) = v["display_author_hover_no_comment"].as_bool() { + self.comment_prefs.display_author_hover_no_comment = display; + } // Per-tool comment styles (#340): each field goes through its clamping setter, so a // malformed or hostile entry is clamped or ignored rather than trusted. if let Some(styles) = v["comment_styles"].as_array() { @@ -2555,6 +2560,13 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("display-author-hover-no-comment", _) => { + self.comment_prefs.display_author_hover_no_comment = match value { + "true" => true, + "false" => false, + _ => return Err("display-author-hover-no-comment must be true or false".into()), + }; + } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, ("system-title-bar", _) => { self.system_title_bar = match value { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..499feaab59 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -350,41 +350,165 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let mut ed = ed; let mut changed = false; let font_choices = app.session.get(app.views[i].id).map(|d| d.text_editor_fonts(ed.page)).unwrap_or_default(); - egui::ComboBox::from_id_salt("document-font-resource") - .selected_text( - ed.font_resource - .as_ref() - .and_then(|r| font_choices.iter().find(|f| &f.resource == r)) - .map_or(tl!("Keep source fonts"), |f| f.face.as_str()), - ) + let current_font_label = if let Some(r) = ed.font_resource.as_ref() { + font_choices.iter().find(|f| &f.resource == r).map_or_else(|| r.clone(), |f| f.face.clone()) + } else if ed.look.family != pdfcraft_engine::FontFamily::Helvetica { + ed.look.family.label().to_string() + } else { + tl!("Keep source fonts").to_string() + }; + + crate::widgets::section_title(ui, tl!("FORMAT TEXT")); + egui::ComboBox::from_id_salt("text-font-dropdown") + .selected_text(current_font_label) + .width(ui.available_width().max(180.0)) .show_ui(ui, |ui| { - changed |= ui.selectable_value(&mut ed.font_resource, None, tl!("Keep source fonts")).changed(); - for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) { - changed |= ui.selectable_value(&mut ed.font_resource, Some(font.resource.clone()), &font.face).changed(); + if ui.selectable_label(ed.font_resource.is_none(), tl!("Keep source fonts")).clicked() { + ed.font_resource = None; + changed = true; } - }); - if ui.button(tl!("Load font…")).clicked() { - app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false); - } - #[cfg(not(target_arch = "wasm32"))] - egui::ComboBox::from_id_salt("installed-document-font").selected_text(tl!("Installed fonts…")).show_ui(ui, |ui| { - if let Some(fonts) = crate::files::installed_font_files(ui.ctx()) { - if fonts.is_empty() { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } - for font in fonts { - if ui.selectable_label(false, &font.face).clicked() { - app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[font.path]); + if !font_choices.is_empty() { + ui.separator(); + ui.label(egui::RichText::new(tl!("Document fonts")).weak().small()); + for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) { + let is_current = ed.font_resource.as_ref() == Some(&font.resource); + if ui.selectable_label(is_current, &font.face).clicked() { + ed.font_resource = Some(font.resource.clone()); + changed = true; + } } } - } else { - ui.label(tl!("Finding document fonts…")); + ui.separator(); + ui.label(egui::RichText::new(tl!("Standard fonts")).weak().small()); + for f in [pdfcraft_engine::FontFamily::Helvetica, pdfcraft_engine::FontFamily::Times, pdfcraft_engine::FontFamily::Courier] { + let is_current = ed.font_resource.is_none() && ed.look.family == f; + if ui.selectable_label(is_current, f.label()).clicked() { + ed.font_resource = None; + ed.look.family = f; + changed = true; + } + } + #[cfg(not(target_arch = "wasm32"))] + { + ui.separator(); + ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); + if let Some(installed) = crate::files::installed_font_files(ui.ctx()) { + if installed.is_empty() { + ui.label(egui::RichText::new(tl!("No installed fonts found.")).weak().small()); + } else { + for font in &installed { + let is_current = ed.font_resource.as_ref().is_some_and(|r| { + font_choices.iter().any(|f| &f.resource == r && f.face == font.face) + }); + if ui.selectable_label(is_current, &font.face).clicked() { + if let Ok(bytes) = std::fs::read(&font.path) { + let edit = pdfcraft_engine::Edit::RegisterTextFont { page: ed.page, bytes: std::sync::Arc::new(bytes.clone()) }; + if app.apply_edit(edit) { + if let Some(doc) = app.session.get(app.views[i].id) { + if let Some(f) = doc.text_editor_fonts(ed.page).into_iter().find(|f| f.bytes == bytes) { + ed.font_resource = Some(f.resource); + changed = true; + } + ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); + } + } + } + } + } + } + } else { + ui.label(egui::RichText::new(tl!("Finding installed fonts…")).weak().small()); + } + } + ui.separator(); + if ui.button(tl!("Load font from file…")).clicked() { + app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false); + } + }); + + ui.horizontal(|ui| { + // Font size ComboBox + egui::ComboBox::from_id_salt("font-size") + .selected_text(format!("{:.1}", ed.look.size)) + .width(55.0) + .show_ui(ui, |ui| { + for size in [6.0, 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { + changed |= ui.selectable_value(&mut ed.look.size, size, format!("{size}")).changed(); + } + }); + // Color swatch square + let (color_rect, color_resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click()); + let color_val = crate::comments::color32(ed.look.color); + ui.painter().rect_filled(color_rect, t.radius, color_val); + ui.painter().rect_stroke(color_rect, t.radius, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let show_palette_id = ui.id().with("show-palette"); + let mut show_palette = ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false); + if color_resp.on_hover_text(tl!("Text color")).clicked() { + show_palette = !show_palette; + ui.data_mut(|d| d.insert_temp(show_palette_id, show_palette)); + } + ui.add_space(2.0); + // Bullet list button + let is_bullet = crate::edit_text_ui::is_bulleted(&ed.text); + if crate::icons::button(ui, "list", 24.0, is_bullet, tl!("Bullet list")).clicked() { + changed |= crate::edit_text_ui::toggle_bullet_list(&mut ed.text); + } + // Numbered list button + let is_num = crate::edit_text_ui::is_numbered(&ed.text); + if crate::icons::button(ui, "list-ordered", 24.0, is_num, tl!("Numbered list")).clicked() { + changed |= crate::edit_text_ui::toggle_numbered_list(&mut ed.text); + } + ui.add_space(2.0); + // Alignment buttons + for (a, icon, tip) in [ + (pdfcraft_engine::TextAlign::Left, "align-left", tl!("Align left")), + (pdfcraft_engine::TextAlign::Center, "align-center", tl!("Centre")), + (pdfcraft_engine::TextAlign::Right, "align-right", tl!("Align right")), + (pdfcraft_engine::TextAlign::Justify, "align-justify", tl!("Justify")), + ] { + if crate::icons::button(ui, icon, 24.0, ed.look.align == a, tip).clicked() { + ed.look.align = a; + changed = true; + } } }); - if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { - ed.look = look; - changed = true; + + // Color Swatch Grid (when toggled or open) + let show_palette_id = ui.id().with("show-palette"); + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { + if let Some(picked) = crate::comments::swatch_grid(ui, Some(ed.look.color)) { + ed.look.color = picked; + changed = true; + } } + + // Formatting Buttons: Bold, Italic, Underline, Superscript, Subscript + ui.horizontal(|ui| { + let b_resp = ui.selectable_label(ed.look.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")); + if b_resp.clicked() { + ed.look.bold = !ed.look.bold; + changed = true; + } + let i_resp = ui.selectable_label(ed.look.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")); + if i_resp.clicked() { + ed.look.italic = !ed.look.italic; + changed = true; + } + if crate::icons::button(ui, "underline", 24.0, ed.extras.underline, tl!("Underline")).clicked() { + ed.extras.underline = !ed.extras.underline; + changed = true; + } + let sup_resp = ui.selectable_label(false, egui::RichText::new("T¹").size(13.0)).on_hover_text(tl!("Superscript")); + if sup_resp.clicked() { + changed |= crate::edit_text_ui::toggle_superscript(&mut ed); + } + let sub_resp = ui.selectable_label(false, egui::RichText::new("T₁").size(13.0)).on_hover_text(tl!("Subscript")); + if sub_resp.clicked() { + changed |= crate::edit_text_ui::toggle_subscript(&mut ed); + } + }); + + // Spacing: Value-based line spacing, paragraph spacing, horizontal scale, character spacing changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 98f2bf9071..a86ee0d76c 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -993,6 +993,55 @@ fn hovering_a_comment_shows_its_author_and_text() { assert!(h.query_by_label_contains("Check this figure").is_none(), "a dialog covers the page hover"); } +#[test] +fn hovering_highlight_without_comment_respects_display_author_option() { + let pdf = String::from_utf8_lossy(TEXT_FIXTURE).replace( + "/Contents 5 0 R /Resources", + "/Contents 5 0 R /Annots [8 0 R 9 0 R] /Resources", + ).replace( + "trailer", + "8 0 obj << /Type /Annot /Subtype /Highlight /Rect [50 50 150 120] /C [1 1 0] /T (Ada) >> endobj\n9 0 obj << /Type /Annot /Subtype /Highlight /Rect [160 50 250 120] /C [1 1 0] /T (Ada) /Contents (Has comment) >> endobj\ntrailer", + ); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("hover.pdf", None, pdf.into_bytes()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app + }); + h.run_steps(4); + settle(&mut h); + + // By default, display_author_hover_no_comment is true. Hovering over empty highlight shows Ada. + let p_empty = at(&h, 100.0, 85.0); + h.hover_at(p_empty); + h.run_steps(3); + h.get_by_label_contains("Ada"); + + // Disable the option. Hovering over empty highlight does NOT show Ada. + h.state_mut().set_option("display-author-hover-no-comment", "false").unwrap(); + h.hover_at(pos2(10.0, 10.0)); + h.run_steps(2); + h.hover_at(p_empty); + h.run_steps(3); + assert!(h.query_by_label_contains("Ada").is_none(), "no author popup when option is disabled and no comment"); + + // But hovering over the highlight with a comment still shows Ada and the comment! + let p_comment = at(&h, 200.0, 85.0); + h.hover_at(p_comment); + h.run_steps(3); + h.get_by_label_contains("Ada"); + h.get_by_label_contains("Has comment"); + + // Settings round-trip test + let json = h.state().persist(); + assert!(json.contains("\"display_author_hover_no_comment\":false")); + let mut app2 = PdfCraftApp::new(); + app2.restore(&json); + assert_eq!(app2.comment_prefs.display_author_hover_no_comment, false); +} + #[test] fn the_squiggly_tool_marks_selected_text() { let mut h = harness(|_| {}); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index c470837775..971c8bf9c8 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2548,6 +2548,187 @@ fn check_existing_artwork(name: &'static str, bytes: Vec) { assert!(h.state().session.get(id).unwrap().page_images(0).is_empty()); } +#[test] +fn image_right_click_context_menu_items_and_actions() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + // Select image + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some(), "selected"); + + // Right-click image + h.hover_at(c); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some()); + + for item in [ + "Cut", + "Copy", + "Paste", + "Flip horizontal", + "Flip vertical", + "Arrange image", + "Replace image", + "Draw freehand", + "Redact image", + "Export image", + "Create link", + "Edit image using", + "Recognize text", + ] { + h.get_by_label_contains(item); + } + + // Top horizontal icon buttons + h.get_by_label("Rotate Counterclockwise"); + h.get_by_label("Rotate Clockwise"); + h.get_by_label("Delete"); + + // Flip horizontal via context menu item + h.get_by_label("Flip horizontal").click(); + h.run_steps(3); + let id = h.state().views[0].id; + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Flip image")); + + // Undo flip + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), None); +} + +#[test] +fn default_page_context_menu_when_not_in_edit_mode() { + let mut h = harness(1, |_| {}); + h.run_steps(4); + assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::Select); + + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + // Right click on page in Select mode + h.hover_at(c); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + // Verify Acrobat page context menu items (Screenshot 3) + h.get_by_label("Rotate Counterclockwise"); + h.get_by_label("Print"); + h.get_by_label("Select Tool"); + h.get_by_label("Hand Tool"); + h.get_by_label("Marquee Zoom"); + + for item in [ + "Add bookmark", + "Take a snapshot", + "Edit this PDF", + "Delete page", + "Export this PDF", + "Find text or tools", + "Document properties…", + ] { + h.get_by_label_contains(item); + } + h.get_by_label("Rotate view clockwise"); + assert!(h.query_all_by_label("Add comment").next().is_some()); + assert!(h.query_all_by_label("Add text").next().is_some()); + assert!(h.query_all_by_label("Add image").next().is_some()); + + // Clicking "Edit this PDF" enters edit mode + h.get_by_label("Edit this PDF").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::EditText); +} + +#[test] +fn image_edge_handle_stretch_and_crop_mode() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(100, 100, image::Rgb([100, 150, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("square.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + // Select image + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some()); + + // Enter crop mode via context menu + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.get_by_label("Crop").click(); + h.run_steps(2); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); + + // Confirm crop with Enter key + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); +} + +#[test] +fn text_selection_action_icons() { + let mut h = harness(1, |_| {}); + h.run_steps(4); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let p0 = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + let p1 = egui::pos2(r.left() + 100.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + + // Drag to select text + h.hover_at(p0); + h.run_steps(1); + h.drag_at(p0); + h.run_steps(1); + h.hover_at(p1); + h.run_steps(1); + h.drop_at(p1); + h.run_steps(2); + + assert!(h.state().views[0].selected_text().is_some()); + + // Verify all 6 minimalist action icons are present in the floating menu + assert!(h.query_all_by_label("Add comment").next().is_some()); + assert!(h.query_all_by_label("Highlight").next().is_some()); + assert!(h.query_all_by_label("Underline").next().is_some()); + assert!(h.query_all_by_label("Strikethrough").next().is_some()); + assert!(h.query_all_by_label("Replace text").next().is_some()); + assert!(h.query_all_by_label("Copy").next().is_some()); +} + #[test] fn the_format_panel_restyles_the_paragraph_being_edited() { let mut h = harness(1, |_| {}); From 293faa0c059bad7f09763eb799f59fa86047b3a9 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 17:06:31 +0530 Subject: [PATCH 005/187] feat: major Acrobat UI parity, crop, and layer fixes --- crates/ui-egui/src/content_ui.rs | 38 +++++++++++------------ crates/ui-egui/src/edit_text_ui.rs | 49 +++++++++++++++--------------- crates/ui-egui/src/panels.rs | 32 +++++++++---------- parity/acrobat-features.toml | 8 ++--- 4 files changed, 64 insertions(+), 63 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 38f63587cb..148830e010 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -561,21 +561,21 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> ui.selectable_value(&mut s.family, f, f.label()); } #[cfg(not(target_arch = "wasm32"))] - if let Some(installed) = crate::files::installed_font_files(ui.ctx()) { - if !installed.is_empty() { - ui.separator(); - ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); - for font in &installed { - let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { - FontFamily::Times - } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { - FontFamily::Courier - } else { - FontFamily::Helvetica - }; - if ui.selectable_label(s.family == fam, &font.face).clicked() { - s.family = fam; - } + if let Some(installed) = crate::files::installed_font_files(ui.ctx()) + && !installed.is_empty() + { + ui.separator(); + ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); + for font in &installed { + let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { + FontFamily::Times + } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { + FontFamily::Courier + } else { + FontFamily::Helvetica + }; + if ui.selectable_label(s.family == fam, &font.face).clicked() { + s.family = fam; } } } @@ -621,10 +621,10 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> } }); let show_palette_id = ui.id().with("show-palette-added"); - if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { - if let Some(picked) = crate::comments::swatch_grid(ui, Some(s.color)) { - s.color = picked; - } + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) + && let Some(picked) = crate::comments::swatch_grid(ui, Some(s.color)) + { + s.color = picked; } ui.horizontal(|ui| { let b_resp = ui.selectable_label(s.bold, egui::RichText::new("T").strong().size(14.0)); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 73d6eaaf41..282ab3eac9 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -179,9 +179,7 @@ pub fn toggle_bullet_list(text: &mut String) -> bool { } else { new_text.push_str(line); } - } else if trimmed.is_empty() { - new_text.push_str(line); - } else if trimmed.starts_with("• ") { + } else if trimmed.is_empty() || trimmed.starts_with("• ") { new_text.push_str(line); } else { new_text.push_str("• "); @@ -218,11 +216,13 @@ pub fn toggle_numbered_list(text: &mut String) -> bool { } let t = line.trim_start(); if all_numbered { - if let Some(dot_idx) = t.find('.') { - if dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") { - new_text.push_str(&t[dot_idx + 2..]); - continue; - } + if let Some(dot_idx) = t.find('.') + && dot_idx > 0 + && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) + && t[dot_idx..].starts_with(". ") + { + new_text.push_str(&t[dot_idx + 2..]); + continue; } new_text.push_str(line); } else if t.is_empty() { @@ -905,10 +905,11 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); draw_handles(b); } - if allow_interaction && let Some(p) = hover_pos { - if let Some(&h) = all_handles.iter().find(|&&h| handle_pos(b, h).distance(p) < 8.0) { - ui.ctx().set_cursor_icon(cursor_for(h)); - } + if allow_interaction + && let Some(p) = hover_pos + && let Some(&h) = all_handles.iter().find(|&&h| handle_pos(b, h).distance(p) < 8.0) + { + ui.ctx().set_cursor_icon(cursor_for(h)); } } @@ -927,10 +928,10 @@ pub(crate) fn image_input( if let Some(s) = view.image_selection.as_mut() { s.drag = Some(ImageDrag { start: o, target: DragTarget::CropHandle(h), initial_rect: active_crop }); } - } else if active_crop.contains(o) { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: active_crop }); - } + } else if active_crop.contains(o) + && let Some(s) = view.image_selection.as_mut() + { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: active_crop }); } } else { let hit_handle = all_handles.iter().copied().find(|&h| handle_pos(b, h).distance(o) < 8.0); @@ -938,10 +939,10 @@ pub(crate) fn image_input( if let Some(s) = view.image_selection.as_mut() { s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(h), initial_rect: b }); } - } else if image_contains(i, o) { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b }); - } + } else if image_contains(i, o) + && let Some(s) = view.image_selection.as_mut() + { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b }); } } } @@ -991,10 +992,10 @@ pub(crate) fn image_input( if let Some(s) = view.image_selection.as_mut() { s.crop_rect = Some(new_crop); } - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - } + if (resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down())) + && let Some(s) = view.image_selection.as_mut() + { + s.drag = None; } ui.ctx().set_cursor_icon(match drag.target { DragTarget::CropHandle(h) => cursor_for(h), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 499feaab59..6f9758ac9a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -400,18 +400,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let is_current = ed.font_resource.as_ref().is_some_and(|r| { font_choices.iter().any(|f| &f.resource == r && f.face == font.face) }); - if ui.selectable_label(is_current, &font.face).clicked() { - if let Ok(bytes) = std::fs::read(&font.path) { - let edit = pdfcraft_engine::Edit::RegisterTextFont { page: ed.page, bytes: std::sync::Arc::new(bytes.clone()) }; - if app.apply_edit(edit) { - if let Some(doc) = app.session.get(app.views[i].id) { - if let Some(f) = doc.text_editor_fonts(ed.page).into_iter().find(|f| f.bytes == bytes) { - ed.font_resource = Some(f.resource); - changed = true; - } - ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); - } + if ui.selectable_label(is_current, &font.face).clicked() + && let Ok(bytes) = std::fs::read(&font.path) + { + let edit = pdfcraft_engine::Edit::RegisterTextFont { page: ed.page, bytes: std::sync::Arc::new(bytes.clone()) }; + if app.apply_edit(edit) + && let Some(doc) = app.session.get(app.views[i].id) + { + if let Some(f) = doc.text_editor_fonts(ed.page).into_iter().find(|f| f.bytes == bytes) { + ed.font_resource = Some(f.resource); + changed = true; } + ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); } } } @@ -475,11 +475,11 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { // Color Swatch Grid (when toggled or open) let show_palette_id = ui.id().with("show-palette"); - if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { - if let Some(picked) = crate::comments::swatch_grid(ui, Some(ed.look.color)) { - ed.look.color = picked; - changed = true; - } + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) + && let Some(picked) = crate::comments::swatch_grid(ui, Some(ed.look.color)) + { + ed.look.color = picked; + changed = true; } // Formatting Buttons: Bold, Italic, Underline, Superscript, Subscript diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 359a71e9a6..0e73ae2150 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2127,11 +2127,11 @@ title = "Arrange objects (bring forward, send backward)" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["image_edit"] +evidence = ["crates/edit/src/tests.rs::image_arrange_stacking_order_and_remove_crop"] +notes = "Image stacking order in content streams: Bring to Front, Send to Back, Bring Forward, Send Backward with undo/redo and PDF export persistence. Image-level crop and 8-point handle transforms. Still missing: arbitrary vector paths, inline images, and multi-object grouping." [[feature]] id = "edit.align-distribute-objects" From 9a5fec0caa5bec25785b5148b8349ebcb1b8369d Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 19:05:51 +0530 Subject: [PATCH 006/187] feat: major Acrobat UI parity, crop, and layer fixes --- crates/edit/src/images.rs | 233 +++++++++++++++++++++-------- crates/edit/src/tests.rs | 26 ++++ crates/ui-egui/src/canvas.rs | 52 +++++-- crates/ui-egui/src/content_ui.rs | 131 ++++++++++++++++ crates/ui-egui/src/create_ui.rs | 2 +- crates/ui-egui/src/edit_text_ui.rs | 68 ++++++++- crates/ui-egui/src/image_pick.rs | 2 +- crates/ui-egui/src/lib.rs | 46 ++++-- crates/ui-egui/src/object_ui.rs | 3 + crates/ui-egui/tests/comments.rs | 2 +- crates/ui-egui/tests/editing.rs | 88 ++++++++++- 11 files changed, 562 insertions(+), 91 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 14556c7644..8bb6949f99 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -660,6 +660,122 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran Ok(()) } +/// Locate the innermost graphics state scope enclosing only `target` (with no other painting operations). +/// Returns `Some((start, end, scope_matrix))` where `start` is the index of `q`, `end` is the index of `Q`, +/// and `scope_matrix` is the CTM in effect immediately before entering that `q`. +fn image_exclusive_scope(ops: &[Op], target: usize) -> Result, EditError> { + struct Scope { + start: usize, + matrix: Matrix, + inherited_clip: bool, + inherited_path: bool, + selected: bool, + other_paint: bool, + } + let mut stack: Vec = Vec::new(); + let mut matrix = Matrix::IDENTITY; + let mut clipped = false; + let mut path = false; + let mut pending_clip = false; + let mut text = false; + let mut at_image = None; + let mut isolated = None; + for (index, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => { + if stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); + } + b"Q" => { + if let Some(scope) = stack.pop() { + if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path { + isolated.get_or_insert((scope.start, index, scope.matrix)); + } + matrix = scope.matrix; + clipped = scope.inherited_clip; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"BT" => text = true, + b"ET" => text = false, + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true, + b"W" | b"W*" => pending_clip = true, + _ => {} + } + if index == target { + if text { + return Err(EditError::Invalid("an image inside a text object cannot be cropped".into())); + } + at_image = Some(matrix); + for scope in &mut stack { + scope.selected = true; + } + } else if matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) { + for scope in &mut stack { + scope.other_paint = true; + } + } + if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { + clipped |= pending_clip; + pending_clip = false; + path = false; + } + } + if at_image.is_none() { + return Err(EditError::Invalid("the image operation is missing".into())); + } + Ok(isolated) +} + +fn extract_image_drawing_ops(ops: &[Op], start: usize, end: usize, scope_matrix: Matrix) -> Vec { + let mut drawing = Vec::new(); + let mut i = start.saturating_add(1); + while i < end { + let op = match ops.get(i) { + Some(o) => o, + None => break, + }; + if op.is("cm") + && scope_matrix != Matrix::IDENTITY + && let (Some(m), Some(inv)) = (op.nums::<6>(), scope_matrix.invert()) + && Matrix(m) == inv + && ops.get(i + 1).is_some_and(|next| next.is("re")) + { + i = i.saturating_add(1); + continue; + } + if op.is("re") { + i = i.saturating_add(1); + if ops.get(i).is_some_and(|next| next.is("W") || next.is("W*")) { + i = i.saturating_add(1); + } + if ops.get(i).is_some_and(|next| next.is("n")) { + i = i.saturating_add(1); + } + if scope_matrix != Matrix::IDENTITY + && ops.get(i).is_some_and(|next| next.is("cm")) + && let Some(m) = ops.get(i).and_then(|next| next.nums::<6>()) + && Matrix(m) == scope_matrix + { + i = i.saturating_add(1); + } + continue; + } + drawing.push(op.clone()); + i = i.saturating_add(1); + } + drawing +} + /// Remove any private clipping path / crop on image `index` on `page`. pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -682,14 +798,19 @@ pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Resul .iter() .position(|op| op.span.start == span && op.is("Do")) .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; - if let Some((at, _)) = exclusive_clip(&ops, target) { - let end_idx = at.saturating_add(2); - if let (Some(start_op), Some(end_op)) = (ops.get(at), ops.get(end_idx)) { - let range = start_op.span.start..end_op.span.end; - let changed = pieces.splice([(range, Vec::new())]); - let contents = crate::text::rewritten_contents(doc, &all, changed); - doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; - } + if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? { + let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix); + let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let range = start_op.span.start..end_op.span.end; + let mut unit_ops = Vec::new(); + unit_ops.push(Op::new("q", vec![])); + unit_ops.extend(drawing_ops); + unit_ops.push(Op::new("Q", vec![])); + let serialized = serialize_ops(&unit_ops); + let changed = pieces.splice([(range, serialized)]); + let contents = crate::text::rewritten_contents(doc, &all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; } Ok(()) } @@ -725,62 +846,52 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64 .position(|op| op.span.start == span && op.is("Do")) .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; - let (range, unit_ops) = if let Some((at, _)) = exclusive_clip(&ops, target) { - let op_re = ops.get(at).ok_or_else(|| EditError::Invalid("the crop operation is missing".into()))?; - ( - op_re.span.start..op_re.span.end, - vec![Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)])], - ) - } else if target >= 2 - && ops.get(target - 2).is_some_and(|o| o.is("q")) - && ops.get(target - 1).is_some_and(|o| o.is("cm")) - && ops.get(target + 1).is_some_and(|o| o.is("Q")) - { - let q_op = &ops[target - 2]; - let q_end = &ops[target + 1]; - ( - q_op.span.start..q_end.span.end, - vec![ - Op::new("q", vec![]), - Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), - Op::new("W", vec![]), - Op::new("n", vec![]), - ops[target - 1].clone(), - ops[target].clone(), - Op::new("Q", vec![]), - ], - ) - } else if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) { - let cm_op = &ops[target - 1]; - let do_op = &ops[target]; - ( - cm_op.span.start..do_op.span.end, - vec![ - Op::new("q", vec![]), - Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), - Op::new("W", vec![]), - Op::new("n", vec![]), - ops[target - 1].clone(), - ops[target].clone(), - Op::new("Q", vec![]), - ], - ) + let (start, end, drawing_ops, scope_matrix) = if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? { + let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix); + (start, end, drawing_ops, scope_matrix) } else { - let do_op = &ops[target]; - ( - do_op.span.start..do_op.span.end, - vec![ - Op::new("q", vec![]), - Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), - Op::new("W", vec![]), - Op::new("n", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops[target].clone(), - Op::new("Q", vec![]), - ], - ) + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut scope_matrix = Matrix::IDENTITY; + let start = if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) { + target - 1 + } else { + target + }; + for (i, o) in ops.iter().enumerate() { + if i == start { + scope_matrix = state.placement.matrix; + break; + } + state.apply(o)?; + } + let drawing_ops = ops.get(start..=target).unwrap_or_default().to_vec(); + (start, target, drawing_ops, scope_matrix) }; + let mut unit_ops = Vec::new(); + unit_ops.push(Op::new("q", vec![])); + if scope_matrix != Matrix::IDENTITY + && let Some(inv) = scope_matrix.invert() + { + unit_ops.push(Op::new("cm", inv.0.map(pdfcraft_content::num).to_vec())); + } + unit_ops.push(Op::new("re", vec![ + pdfcraft_content::num(x0), + pdfcraft_content::num(y0), + pdfcraft_content::num(w), + pdfcraft_content::num(h), + ])); + unit_ops.push(Op::new("W", vec![])); + unit_ops.push(Op::new("n", vec![])); + if scope_matrix != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", scope_matrix.0.map(pdfcraft_content::num).to_vec())); + } + unit_ops.extend(drawing_ops); + unit_ops.push(Op::new("Q", vec![])); + + let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let range = start_op.span.start..end_op.span.end; let serialized = serialize_ops(&unit_ops); let changed = pieces.splice([(range, serialized)]); let contents = crate::text::rewritten_contents(doc, &all, changed); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index c3aa428f84..a9f61f8bbd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1740,6 +1740,32 @@ fn image_arrange_stacking_order_and_remove_crop() { let doc = reopen(&doc); let img_restored = &images::page_images(&doc, 0).unwrap()[0]; assert_eq!(img_restored.visible_rect, Some(img_restored.rect)); + + // Test cropping an image that was moved (has an outer transform) + let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q"); + let move_transform = [1.0, 0.0, 0.0, 1.0, 50.0, 40.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(move_transform)).unwrap(); + let doc = reopen(&doc); + let img_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]); + let mut doc = doc; + images::crop_image(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap(); + let doc = reopen(&doc); + let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted"); + assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0])); + // Adjust crop (re-crop) + let mut doc = doc; + images::crop_image(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_recropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0])); + // Remove crop from moved image + let mut doc = doc; + images::remove_image_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8f21dcdfe2..aacc091bff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -542,6 +542,8 @@ pub(crate) enum PageMenuAction { SelectTool, HandTool, MarqueeZoom, + Copy, + Paste(usize, [f64; 2]), AddComment(usize, [f64; 2]), AddBookmark(usize), TakeSnapshot, @@ -558,6 +560,8 @@ pub(crate) fn default_page_context_menu( ui: &mut egui::Ui, page: usize, at: [f64; 2], + can_copy: bool, + can_paste: bool, action: &mut Option, ) { // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom @@ -587,6 +591,17 @@ pub(crate) fn default_page_context_menu( ui.separator(); + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() { + *action = Some(PageMenuAction::Copy); + ui.close(); + } + if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() { + *action = Some(PageMenuAction::Paste(page, at)); + ui.close(); + } + + ui.separator(); + if ui.button(tl!("Add comment")).clicked() { *action = Some(PageMenuAction::AddComment(page, at)); ui.close(); @@ -2191,6 +2206,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); + let can_paste_image = app.can_paste_image(); let view = &mut app.views[index]; // Opened without the owner password and something is restricted. let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); @@ -3227,7 +3243,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { return; } if tool == QuickTool::EditText && let Some(sel) = view.image_selection.clone() { - crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, &mut image_action); + crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, can_paste_image, &mut image_action); return; } if view.comments.selected.is_some() { @@ -3260,11 +3276,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.separator(); } } + let has_copy_image = copy_image.is_some(); if copy_image.is_some() { canvas_action = copy_image; } let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0])); - default_page_context_menu(ui, page, at, &mut page_action); + let can_copy = view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image; + let can_paste = can_paste_image; + default_page_context_menu(ui, page, at, can_copy, can_paste, &mut page_action); }); (wanted, visible_now) }); @@ -3531,13 +3550,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(crate::edit_text_ui::ImageAction::Copy(page, i)) => app.copy_page_image(page, i), Some(crate::edit_text_ui::ImageAction::CopyFile(page, i)) => app.copy_page_image_file(page, i), Some(crate::edit_text_ui::ImageAction::Cut(page, i)) => { - app.copy_page_image(page, i); - app.views[index].pending_edit = Some(pdfcraft_engine::Edit::EditPageImage { - page, - index: i, - change: pdfcraft_engine::ImageEdit::Delete, - }); - app.views[index].image_selection = None; + app.cut_page_image(index, page, i); + } + Some(crate::edit_text_ui::ImageAction::Paste(page, rect)) => { + let offset_rect = [rect[0] + 20.0, rect[1] - 20.0, rect[2] + 20.0, rect[3] - 20.0]; + app.paste_image(page, Some(offset_rect)); } Some(crate::edit_text_ui::ImageAction::DrawFreehand) => { app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink); @@ -3591,6 +3608,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } match page_action { + Some(PageMenuAction::Copy) => { + if let Some(sel) = app.views[index].image_selection.clone() { + app.copy_page_image(sel.page, sel.index); + } else if let Some(text) = app.views[index].selected_text() { + if let Some(id) = app.views.get(index).map(|v| v.id) && app.may_copy(id) { + ui.ctx().copy_text(text); + } + } else if let Some((page, at)) = app.views[index].comments.context_at + && let Some(doc) = app.session.get(app.views[index].id) + && let Some(image) = doc.page_image_at(page, at) + { + app.copy_page_image(page, image); + } + } + Some(PageMenuAction::Paste(page, at)) => { + app.paste_image_at(page, at); + } Some(PageMenuAction::RotateCCW(page)) => { app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: -90 }); } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 148830e010..20a609ad3e 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -745,6 +745,15 @@ impl crate::PdfCraftApp { if !self.may_copy(id) { return; } + let img_rect = self.session.get(id).and_then(|d| d.page_images(page).into_iter().nth(index).map(|im| im.rect)); + self.last_copied_image_rect = img_rect; + if let Some(doc) = self.session.get(id) + && let Ok((ext, bytes)) = doc.page_image_copy_file(page, index) + { + let stem = crate::image_pick::safe_name(doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(s, _)| s)); + let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1)); + self.last_copied_file = Some((name, bytes)); + } let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return }; let copied = pixels.and_then(|(w, h, rgba)| { #[cfg(not(target_arch = "wasm32"))] @@ -765,6 +774,128 @@ impl crate::PdfCraftApp { } } + /// Cut Image: copy image `index` on `page` to the clipboard, then delete it. + pub(crate) fn cut_page_image(&mut self, view_idx: usize, page: usize, index: usize) { + self.copy_page_image(page, index); + if let Some(view) = self.views.get_mut(view_idx) { + view.pending_edit = Some(pdfcraft_engine::Edit::EditPageImage { + page, + index, + change: pdfcraft_engine::ImageEdit::Delete, + }); + view.image_selection = None; + } + } + + /// Whether there is an image in the in-memory clipboard or system clipboard ready to paste. + pub fn can_paste_image(&self) -> bool { + if self.last_copied_image.is_some() || self.last_copied_file.is_some() { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + { + if self.system_clipboard + && let Ok(mut cb) = arboard::Clipboard::new() + { + return cb.get_image().is_ok(); + } + } + false + } + + /// Paste an image from the clipboard onto `page`. + pub fn paste_image(&mut self, page: usize, rect: Option<[f64; 4]>) -> bool { + let Some((i, id)) = self.active_ids() else { return false; }; + if let Some(doc) = self.session.get(id) + && !doc.allows_modification() + { + self.notify_error(tl!("The document's security settings don't allow changes")); + return false; + } + + let image_data: Option<(String, Vec)> = if let Some((name, bytes)) = self.last_copied_file.clone() { + Some((name, bytes)) + } else if let Some((w, h, rgba)) = self.last_copied_image.clone() { + crate::create_ui::png_from_rgba(w as usize, h as usize, &rgba).ok().map(|b| ("pasted_image.png".to_string(), b)) + } else { + #[cfg(not(target_arch = "wasm32"))] + { + if self.system_clipboard { + arboard::Clipboard::new() + .ok() + .and_then(|mut cb| cb.get_image().ok()) + .and_then(|img| { + crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes) + .ok() + .map(|b| ("clipboard_image.png".to_string(), b)) + }) + } else { + None + } + } + #[cfg(target_arch = "wasm32")] + None + }; + + let Some((name, bytes)) = image_data else { + self.notify_tr("No image on clipboard to paste"); + return false; + }; + + let target_rect = match rect { + Some(r) => Some(r), + None => { + if let Some(src_rect) = self.last_copied_image_rect { + let w = src_rect[2] - src_rect[0]; + let h = src_rect[3] - src_rect[1]; + let offset_x = 20.0; + let offset_y = -20.0; + let new_r = [src_rect[0] + offset_x, src_rect[1] + offset_y, src_rect[0] + offset_x + w, src_rect[1] + offset_y + h]; + self.last_copied_image_rect = Some(new_r); + Some(new_r) + } else { + None + } + } + }; + + let before = self.session.get(id).map_or(0, |d| d.page_images(page).len()); + if self.apply_edit(pdfcraft_engine::Edit::AddImage { + page, + rect: target_rect, + name, + bytes: std::sync::Arc::new(bytes), + }) { + if let Some(v) = self.views.get_mut(i) { + v.current = page; + v.image_selection = Some(crate::edit_text_ui::ImageSelection { + page, + index: before, + crop_mode: false, + crop_rect: None, + drag: None, + }); + } + self.quick_tool = crate::QuickTool::EditText; + self.notify_tr("Image pasted"); + true + } else { + false + } + } + + /// Paste an image centered at context menu point `at: [f64; 2]` on `page`. + pub fn paste_image_at(&mut self, page: usize, at: [f64; 2]) -> bool { + let rect = if let Some(src_r) = self.last_copied_image_rect { + let w = src_r[2] - src_r[0]; + let h = src_r[3] - src_r[1]; + Some([at[0] - w * 0.5, at[1] - h * 0.5, at[0] + w * 0.5, at[1] + h * 0.5]) + } else { + None + }; + self.paste_image(page, rect) + } + /// Click an image field: pick a picture for it. pub fn choose_field_image(&mut self, name: &str) { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 63f8035251..fb8f1b2e27 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -66,7 +66,7 @@ fn read_clipboard() -> Option { cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text) } -fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { +pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?); if w == 0 || h == 0 || rgba.len() != width * height * 4 { return Err("the clipboard image is empty or malformed".into()); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 282ab3eac9..48b8a7d552 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -616,7 +616,7 @@ impl ImageSelection { /// applies after the pointer leaves the image/page while dragging. pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; - if selection.drag.is_some() { + if selection.drag.is_some() || selection.crop_mode { return true; } let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; @@ -698,6 +698,7 @@ pub(crate) enum ImageAction { Copy(usize, usize), CopyFile(usize, usize), Cut(usize, usize), + Paste(usize, [f64; 4]), DrawFreehand, Redact(usize, usize), CreateLink(usize, usize), @@ -858,14 +859,22 @@ pub(crate) fn image_input( painter.line_segment([Pos2::new(active_crop.left(), y2), Pos2::new(active_crop.right(), y2)], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); let crop_handles = [ + ImageHandle::TopLeft, ImageHandle::TopCenter, + ImageHandle::TopRight, ImageHandle::RightCenter, + ImageHandle::BottomRight, ImageHandle::BottomCenter, + ImageHandle::BottomLeft, ImageHandle::LeftCenter, ]; for &h in &crop_handles { let c = handle_pos(active_crop, h); - let sz = if matches!(h, ImageHandle::TopCenter | ImageHandle::BottomCenter) { egui::vec2(16.0, 7.0) } else { egui::vec2(7.0, 16.0) }; + let sz = match h { + ImageHandle::TopCenter | ImageHandle::BottomCenter => egui::vec2(16.0, 6.0), + ImageHandle::LeftCenter | ImageHandle::RightCenter => egui::vec2(6.0, 16.0), + _ => egui::vec2(10.0, 10.0), + }; painter.rect(Rect::from_center_size(c, sz), CornerRadius::same(1), Color32::WHITE, Stroke::new(1.5, ACCENT), egui::StrokeKind::Middle); } @@ -878,13 +887,21 @@ pub(crate) fn image_input( } if allow_interaction { - if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + let enter_pressed = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let dbl_click = resp.double_clicked() && hover_pos.is_some_and(|p| active_crop.contains(p)); + if enter_pressed || dbl_click { if active_crop != b { view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, active_crop)), }); + } else if images.get(i).is_some_and(|im| im.visible_rect.is_some() && im.visible_rect != Some(im.rect)) { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: i, + change: pdfcraft_engine::ImageEdit::RemoveCrop, + }); } if let Some(s) = view.image_selection.as_mut() { s.crop_mode = false; @@ -892,7 +909,7 @@ pub(crate) fn image_input( } return true; } - if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) { + if ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { if let Some(s) = view.image_selection.as_mut() { s.crop_mode = false; s.crop_rect = None; @@ -918,9 +935,13 @@ pub(crate) fn image_input( if is_crop { let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b); let crop_handles = [ + ImageHandle::TopLeft, ImageHandle::TopCenter, + ImageHandle::TopRight, ImageHandle::RightCenter, + ImageHandle::BottomRight, ImageHandle::BottomCenter, + ImageHandle::BottomLeft, ImageHandle::LeftCenter, ]; let hit_edge = crop_handles.iter().copied().find(|&h| handle_pos(active_crop, h).distance(o) < 8.0); @@ -955,6 +976,26 @@ pub(crate) fn image_input( if is_crop { let mut new_crop = initial; match drag.target { + DragTarget::CropHandle(ImageHandle::TopLeft) => { + let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + new_crop = Rect::from_min_max(Pos2::new(x, y), initial.max); + } + DragTarget::CropHandle(ImageHandle::TopRight) => { + let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), Pos2::new(x, initial.bottom())); + } + DragTarget::CropHandle(ImageHandle::BottomLeft) => { + let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), Pos2::new(initial.right(), y)); + } + DragTarget::CropHandle(ImageHandle::BottomRight) => { + let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + new_crop = Rect::from_min_max(initial.min, Pos2::new(x, y)); + } DragTarget::CropHandle(ImageHandle::TopCenter) => { let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max); @@ -1072,8 +1113,15 @@ pub(crate) fn image_input( && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) && !ui.ctx().egui_wants_keyboard_input() { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); + if is_crop { + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = false; + s.crop_rect = None; + } + } else { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); + } return true; } } @@ -1083,6 +1131,7 @@ pub(crate) fn image_input( let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { let over_popup = egui::Popup::is_any_open(ui.ctx()) + || resp.context_menu_opened() || ui.ctx().is_pointer_over_egui() || ui.ctx().egui_wants_pointer_input(); if (resp.secondary_clicked() || resp.clicked()) && !over_popup { @@ -1124,6 +1173,7 @@ pub(crate) fn image_context_menu( doc: &pdfcraft_engine::Document, page: usize, hit: usize, + can_paste: bool, action: &mut Option, ) { let images = doc.page_images(page); @@ -1171,8 +1221,10 @@ pub(crate) fn image_context_menu( *action = Some(ImageAction::Copy(page, hit)); ui.close(); } - // Paste item: disabled when no item can be pasted into the image layer - ui.add_enabled(false, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")); + if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() { + *action = Some(ImageAction::Paste(page, img.rect)); + ui.close(); + } ui.separator(); diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index a05aefe2d2..3030bfb293 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -59,7 +59,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: } /// A file name without characters file systems refuse. -fn safe_name(s: &str) -> String { +pub(crate) fn safe_name(s: &str) -> String { let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect(); let cleaned = cleaned.trim().trim_matches('.').to_string(); if cleaned.is_empty() { "PdfCraft".into() } else { cleaned } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index de858de5cd..1fddfa5363 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -704,6 +704,7 @@ pub struct PdfCraftApp { /// The last image copied in its original format (file name, bytes), also put on the /// system clipboard as a file when `system_clipboard` is on. pub last_copied_file: Option<(String, Vec)>, + pub last_copied_image_rect: Option<[f64; 4]>, pub system_clipboard: bool, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, @@ -992,6 +993,7 @@ impl PdfCraftApp { last_snapshot: None, last_copied_image: None, last_copied_file: None, + last_copied_image_rect: None, system_clipboard: true, attach_override: None, duplicate_draft: None, @@ -2682,7 +2684,7 @@ impl PdfCraftApp { object_ui::nudge_keys(ctx, &mut self.views[i], doc); } canvas::shortcuts(&mut self.views[i], ctx); - self.copy_key(i, ctx); + self.clipboard_keys(i, ctx); self.image_pick_keys(i, ctx); } } @@ -2706,16 +2708,42 @@ impl PdfCraftApp { false } - /// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own). - fn copy_key(&mut self, i: usize, ctx: &egui::Context) { - // ⌘C arrives as a Copy event on most platforms. - let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))); - if !copy || ctx.egui_wants_keyboard_input() { + /// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus). + fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { + use egui::{Key, KeyboardShortcut, Modifiers}; + if ctx.egui_wants_keyboard_input() || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) { return; } - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))) else { return }; - if self.may_copy(id) { - ctx.copy_text(text); + let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C))); + let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X))); + let paste = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Paste(_)))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); + + if copy { + if let Some(text) = self.views.get(i).and_then(|v| v.selected_text()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + ctx.copy_text(text); + } else if let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + self.copy_page_image(sel.page, sel.index); + } + } + if cut + && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + self.cut_page_image(i, sel.page, sel.index); + } + if paste { + let page = self.views.get(i).map_or(0, |v| v.current); + self.paste_image(page, None); } } diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 118946bd0c..3b410be261 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -160,6 +160,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p if view.objects.input_blocked { return true; } + if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) { + return false; + } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); let modifier_press = ui.input(|i| i.pointer.primary_pressed()) diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index a86ee0d76c..b570a050e1 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1039,7 +1039,7 @@ fn hovering_highlight_without_comment_respects_display_author_option() { assert!(json.contains("\"display_author_hover_no_comment\":false")); let mut app2 = PdfCraftApp::new(); app2.restore(&json); - assert_eq!(app2.comment_prefs.display_author_hover_no_comment, false); + assert!(!app2.comment_prefs.display_author_hover_no_comment); } #[test] diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 971c8bf9c8..daa2c36744 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2654,6 +2654,8 @@ fn default_page_context_menu_when_not_in_edit_mode() { assert!(h.query_all_by_label("Add comment").next().is_some()); assert!(h.query_all_by_label("Add text").next().is_some()); assert!(h.query_all_by_label("Add image").next().is_some()); + assert!(h.query_all_by_label_contains("Copy").next().is_some()); + assert!(h.query_all_by_label_contains("Paste").next().is_some()); // Clicking "Edit this PDF" enters edit mode h.get_by_label("Edit this PDF").click(); @@ -2694,10 +2696,30 @@ fn image_edge_handle_stretch_and_crop_mode() { h.run_steps(2); assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); + // Set smaller crop rect + let smaller = egui::Rect::from_center_size(c, egui::vec2(50.0, 50.0)); + h.state_mut().views[0].image_selection.as_mut().unwrap().crop_rect = Some(smaller); + // Confirm crop with Enter key h.key_press(egui::Key::Enter); - h.run_steps(2); + h.run_steps(4); assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); + let doc_id = h.state().views[0].id; + let images = h.state().session.get(doc_id).unwrap().page_images(0); + assert_eq!(images.len(), 1, "image still exists: {images:?}"); + assert!(images[0].visible_rect.is_some(), "cropped image is visible"); + assert_ne!(images[0].visible_rect, Some(images[0].rect), "image is cropped"); + + // Remove crop via context menu + h.hover_at(c); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.get_by_label("Remove crop").click(); + h.run_steps(4); + let images = h.state().session.get(doc_id).unwrap().page_images(0); + assert_eq!(images[0].visible_rect, Some(images[0].rect), "crop is removed"); } #[test] @@ -4341,3 +4363,67 @@ fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() { assert_eq!(std::fs::read(path).unwrap(), original); println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages"); } + +#[test] +fn image_copy_cut_paste_keyboard_and_context_menu() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(60, 40, image::Rgb([180, 50, 50])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.system_clipboard = false; + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + let id = h.state().views[0].id; + let initial_count = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(initial_count, 1, "starts with 1 image"); + + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + + // 1. Select the image + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some(), "image selected"); + + // 2. Copy via keyboard shortcut (COMMAND+C / Event::Copy) + h.event(egui::Event::Copy); + h.run_steps(2); + assert!(h.state().last_copied_image.is_some(), "image copied to clipboard buffer"); + + // 3. Paste via keyboard shortcut (COMMAND+V / Event::Paste) + h.event(egui::Event::Paste("".into())); + h.run_steps(3); + let after_paste = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(after_paste, 2, "now has 2 images"); + assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(1), "new image is selected"); + + // 4. Cut via keyboard shortcut (COMMAND+X / Event::Cut) + h.event(egui::Event::Cut); + h.run_steps(3); + let after_cut = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(after_cut, 1, "cut deleted the pasted image"); + assert!(h.state().views[0].image_selection.is_none(), "selection cleared after cut"); + + // 5. Paste again via context menu on the page + h.hover_at(c); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.get_by_label_contains("Paste").click(); + h.run_steps(3); + let after_paste2 = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(after_paste2, 2, "pasted via context menu"); +} From bf4c238eba431781ef111d81ed1c421fa940968c Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 20:40:13 +0530 Subject: [PATCH 007/187] Fixes As Requested [Arranging works,Cropping fix, Small size issue,fonts,image override] --- crates/edit/src/images.rs | 95 ++++++++++++++++++++---------- crates/edit/src/tests.rs | 37 ++++++++++++ crates/ui-egui/src/content_ui.rs | 74 +++++++++++------------ crates/ui-egui/src/edit_text_ui.rs | 56 ++++++++++++++---- crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/tests/editing.rs | 54 ++++++++++++++++- 6 files changed, 233 insertions(+), 86 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 8bb6949f99..be963244e0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -603,33 +603,72 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran }; let Some(target_unit) = units.get(target_idx) else { return Ok(()) }; - let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + let has_other_paint = ops.iter().enumerate().any(|(i, op)| { + i >= target_unit.start + && i < target_unit.end + && i != target + && matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) + }); - let unit_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { - if placement == Matrix::IDENTITY { - ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + let (target_span, unit_ops) = if has_other_paint { + if let Ok(Some((sub_start, sub_end, sub_placement))) = image_exclusive_scope(&ops, target) + && sub_start >= target_unit.start + && sub_end < target_unit.end + { + let u_ops = if sub_placement == Matrix::IDENTITY { + ops.get(sub_start..=sub_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", sub_placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(sub_start.saturating_add(1)..=sub_end) { + out.extend_from_slice(inner); + } + out + }; + let start_op = ops.get(sub_start).unwrap_or(&ops[target]); + let end_op = ops.get(sub_end).unwrap_or(&ops[target]); + (start_op.span.start..end_op.span.end, u_ops) } else { - let mut out = Vec::new(); - out.push(Op::new("q", vec![])); - out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); - if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { - out.extend_from_slice(inner); - } - out + let u_ops = vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ]; + let target_op = &ops[target]; + (target_op.span.start..target_op.span.end, u_ops) } } else { - vec![ - Op::new("q", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), - Op::new("Q", vec![]), - ] + let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + let u_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { + if placement == Matrix::IDENTITY { + ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { + out.extend_from_slice(inner); + } + out + } + } else { + vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ] + }; + let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; + let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + (first_op.span.start..last_op.span.end, u_ops) }; - let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; - let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - let target_span = first_op.span.start..last_op.span.end; - let dest_pos = if insert_after { let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; op.span.end @@ -851,21 +890,15 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64 (start, end, drawing_ops, scope_matrix) } else { let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut scope_matrix = Matrix::IDENTITY; - let start = if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) { - target - 1 - } else { - target - }; for (i, o) in ops.iter().enumerate() { - if i == start { - scope_matrix = state.placement.matrix; + if i == target { break; } state.apply(o)?; } - let drawing_ops = ops.get(start..=target).unwrap_or_default().to_vec(); - (start, target, drawing_ops, scope_matrix) + let scope_matrix = state.placement.matrix; + let drawing_ops = vec![ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)]))]; + (target, target, drawing_ops, scope_matrix) }; let mut unit_ops = Vec::new(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a9f61f8bbd..9d74024daa 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1768,6 +1768,43 @@ fn image_arrange_stacking_order_and_remove_crop() { assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); } +#[test] +fn image_arrange_preserves_shared_group_drawings() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (End Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2, "both images initially present in shared group"); + + // Bring image 0 to front: it must not delete image 1 that shares the same q...Q group! + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must be preserved after BringToFront"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("End Text"), "text in page must be preserved"); +} + +#[test] +fn image_crop_preserves_outer_cm_for_subsequent_drawings() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Crop image 0 + images::crop_image(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must remain"); + // Image 0 is cropped: + assert!(close(after[0].visible_rect.unwrap(), [60.0, 60.0, 120.0, 120.0])); + // Crucial: Image 1 must still have its rect at [50, 50, 150, 150], proving outer cm was not destroyed! + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 20a609ad3e..1b625e1290 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -560,25 +560,6 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { ui.selectable_value(&mut s.family, f, f.label()); } - #[cfg(not(target_arch = "wasm32"))] - if let Some(installed) = crate::files::installed_font_files(ui.ctx()) - && !installed.is_empty() - { - ui.separator(); - ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); - for font in &installed { - let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { - FontFamily::Times - } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { - FontFamily::Courier - } else { - FontFamily::Helvetica - }; - if ui.selectable_label(s.family == fam, &font.face).clicked() { - s.family = fam; - } - } - } }); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("font-size") @@ -789,18 +770,16 @@ impl crate::PdfCraftApp { /// Whether there is an image in the in-memory clipboard or system clipboard ready to paste. pub fn can_paste_image(&self) -> bool { - if self.last_copied_image.is_some() || self.last_copied_file.is_some() { - return true; - } #[cfg(not(target_arch = "wasm32"))] { if self.system_clipboard && let Ok(mut cb) = arboard::Clipboard::new() + && cb.get_image().is_ok() { - return cb.get_image().is_ok(); + return true; } } - false + self.last_copied_image.is_some() || self.last_copied_file.is_some() } /// Paste an image from the clipboard onto `page`. @@ -813,27 +792,40 @@ impl crate::PdfCraftApp { return false; } - let image_data: Option<(String, Vec)> = if let Some((name, bytes)) = self.last_copied_file.clone() { + #[cfg(not(target_arch = "wasm32"))] + let os_png = if self.system_clipboard { + if let Ok(mut cb) = arboard::Clipboard::new() + && let Ok(img) = cb.get_image() + { + let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { + *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() + }); + if !matches_internal { + crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes).ok().map(|bytes| { + (img.width as u32, img.height as u32, img.bytes.to_vec(), bytes) + }) + } else { + None + } + } else { + None + } + } else { + None + }; + #[cfg(target_arch = "wasm32")] + let os_png: Option<(u32, u32, Vec, Vec)> = None; + + let image_data: Option<(String, Vec)> = if let Some((w, h, rgba, png_bytes)) = os_png { + self.last_copied_image = Some((w, h, rgba)); + self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); + self.last_copied_image_rect = None; + Some(("clipboard_image.png".to_string(), png_bytes)) + } else if let Some((name, bytes)) = self.last_copied_file.clone() { Some((name, bytes)) } else if let Some((w, h, rgba)) = self.last_copied_image.clone() { crate::create_ui::png_from_rgba(w as usize, h as usize, &rgba).ok().map(|b| ("pasted_image.png".to_string(), b)) } else { - #[cfg(not(target_arch = "wasm32"))] - { - if self.system_clipboard { - arboard::Clipboard::new() - .ok() - .and_then(|mut cb| cb.get_image().ok()) - .and_then(|img| { - crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes) - .ok() - .map(|b| ("clipboard_image.png".to_string(), b)) - }) - } else { - None - } - } - #[cfg(target_arch = "wasm32")] None }; diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 48b8a7d552..7af7b1091e 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -975,41 +975,73 @@ pub(crate) fn image_input( let initial = drag.initial_rect; if is_crop { let mut new_crop = initial; + let img_screen_w = b.width().max(0.0); + let img_screen_h = b.height().max(0.0); + let safe_min_w = 10.0_f32.min(img_screen_w).min(initial.width().max(0.0)).max(0.0); + let safe_min_h = 10.0_f32.min(img_screen_h).min(initial.height().max(0.0)).max(0.0); match drag.target { DragTarget::CropHandle(ImageHandle::TopLeft) => { - let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); - let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); + + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(Pos2::new(x, y), initial.max); } DragTarget::CropHandle(ImageHandle::TopRight) => { - let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); - let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); + + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), Pos2::new(x, initial.bottom())); } DragTarget::CropHandle(ImageHandle::BottomLeft) => { - let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); - let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); + + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), Pos2::new(initial.right(), y)); } DragTarget::CropHandle(ImageHandle::BottomRight) => { - let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); - let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); + + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(initial.min, Pos2::new(x, y)); } DragTarget::CropHandle(ImageHandle::TopCenter) => { - let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max); } DragTarget::CropHandle(ImageHandle::BottomCenter) => { - let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(initial.min, Pos2::new(initial.right(), y)); } DragTarget::CropHandle(ImageHandle::LeftCenter) => { - let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), initial.max); } DragTarget::CropHandle(ImageHandle::RightCenter) => { - let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); new_crop = Rect::from_min_max(initial.min, Pos2::new(x, initial.bottom())); } DragTarget::Move => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1fddfa5363..49fb3b5fcb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -73,7 +73,8 @@ mod autoscroll; mod bidi; pub mod bulk_fields; mod dialogs; -mod edit_text_ui; +pub mod edit_text_ui; +pub use edit_text_ui::{DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; #[cfg(not(target_arch = "wasm32"))] pub mod file_watch; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index daa2c36744..f7630f955d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2717,11 +2717,63 @@ fn image_edge_handle_stretch_and_crop_mode() { h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); h.run_steps(2); h.get_by_label("Remove crop").click(); - h.run_steps(4); let images = h.state().session.get(doc_id).unwrap().page_images(0); assert_eq!(images[0].visible_rect, Some(images[0].rect), "crop is removed"); } +#[test] +fn tiny_image_crop_handle_drag_does_not_panic() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(5, 5, image::Rgb([100, 150, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("tiny.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + // Enter crop mode on image 0 + let view = &mut h.state_mut().views[0]; + view.image_selection = Some(pdfcraft_ui_egui::ImageSelection { + page: 0, + index: 0, + drag: None, + crop_mode: true, + crop_rect: None, + }); + h.run_steps(1); + + // Simulate drag on TopLeft crop handle with an initial_rect of 5.0 x 5.0 + let initial = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(5.0, 5.0)); + let view = &mut h.state_mut().views[0]; + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(pdfcraft_ui_egui::ImageDrag { + start: egui::pos2(100.0, 100.0), + target: pdfcraft_ui_egui::DragTarget::CropHandle(pdfcraft_ui_egui::ImageHandle::TopLeft), + initial_rect: initial, + }); + } + + // Hover/drag to a new position, triggering the clamp calculation + h.hover_at(egui::pos2(105.0, 105.0)); + h.run_steps(2); + + // Also test BottomRight handle + let view = &mut h.state_mut().views[0]; + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(pdfcraft_ui_egui::ImageDrag { + start: egui::pos2(105.0, 105.0), + target: pdfcraft_ui_egui::DragTarget::CropHandle(pdfcraft_ui_egui::ImageHandle::BottomRight), + initial_rect: initial, + }); + } + h.hover_at(egui::pos2(95.0, 95.0)); + h.run_steps(2); +} + #[test] fn text_selection_action_icons() { let mut h = harness(1, |_| {}); From fde5abb1230f218a93d400ffdb0b1c1f2002a9a8 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 21:16:41 +0530 Subject: [PATCH 008/187] Fixes As Requested [image transformer,Image extraction,stale cached image] --- crates/edit/src/images.rs | 110 +++++++++++++++++-------------- crates/edit/src/tests.rs | 41 ++++++++++++ crates/ui-egui/src/content_ui.rs | 52 ++++++++------- crates/ui-egui/tests/editing.rs | 24 +++++++ 4 files changed, 154 insertions(+), 73 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index be963244e0..b54ecd5bf5 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -613,69 +613,81 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran ) }); - let (target_span, unit_ops) = if has_other_paint { - if let Ok(Some((sub_start, sub_end, sub_placement))) = image_exclusive_scope(&ops, target) + let (dest_pos, scan_target) = if insert_after { + let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + (op.span.end, dest_unit.end) + } else { + let Some(op) = ops.get(dest_unit.start) else { return Ok(()) }; + (op.span.start, dest_unit.start) + }; + + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + for (i, op) in ops.iter().enumerate() { + if i == scan_target { + break; + } + state.apply(op)?; + } + let dest_ctm = state.placement.matrix; + let Some(dest_inv) = dest_ctm.invert() else { return Ok(()); }; + + let target_span = if has_other_paint { + if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) && sub_start >= target_unit.start && sub_end < target_unit.end { - let u_ops = if sub_placement == Matrix::IDENTITY { - ops.get(sub_start..=sub_end).unwrap_or_default().to_vec() - } else { - let mut out = Vec::new(); - out.push(Op::new("q", vec![])); - out.push(Op::new("cm", sub_placement.0.map(pdfcraft_content::num).to_vec())); - if let Some(inner) = ops.get(sub_start.saturating_add(1)..=sub_end) { - out.extend_from_slice(inner); - } - out - }; let start_op = ops.get(sub_start).unwrap_or(&ops[target]); let end_op = ops.get(sub_end).unwrap_or(&ops[target]); - (start_op.span.start..end_op.span.end, u_ops) + start_op.span.start..end_op.span.end } else { - let u_ops = vec![ - Op::new("q", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), - Op::new("Q", vec![]), - ]; let target_op = &ops[target]; - (target_op.span.start..target_op.span.end, u_ops) + target_op.span.start..target_op.span.end } } else { - let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); - let u_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { - if placement == Matrix::IDENTITY { - ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() - } else { - let mut out = Vec::new(); - out.push(Op::new("q", vec![])); - out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); - if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { - out.extend_from_slice(inner); - } - out - } - } else { - vec![ - Op::new("q", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), - Op::new("Q", vec![]), - ] - }; let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - (first_op.span.start..last_op.span.end, u_ops) + first_op.span.start..last_op.span.end }; - let dest_pos = if insert_after { - let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - op.span.end + let is_clipped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); + if has_other_paint && is_clipped && img.clips.len() > 1 { + // Safely decline extraction when complex multi-path clipping cannot be preserved + return Ok(()); + } + + let mut unit_ops = Vec::new(); + unit_ops.push(Op::new("q", vec![])); + if is_clipped { + if dest_ctm != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", dest_inv.0.map(pdfcraft_content::num).to_vec())); + } + if let Some(clip) = img.visible_rect { + let cx0 = clip[0]; + let cy0 = clip[1]; + let cw = clip[2] - clip[0]; + let ch = clip[3] - clip[1]; + if cw > 0.0 && ch > 0.0 { + unit_ops.push(Op::new("re", vec![ + pdfcraft_content::num(cx0), + pdfcraft_content::num(cy0), + pdfcraft_content::num(cw), + pdfcraft_content::num(ch), + ])); + unit_ops.push(Op::new("W", vec![])); + unit_ops.push(Op::new("n", vec![])); + } + } + if Matrix(img.draw_matrix) != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec())); + } } else { - let Some(op) = ops.get(dest_unit.start) else { return Ok(()) }; - op.span.start - }; + let local = Matrix(img.draw_matrix).then(&dest_inv); + if local != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", local.0.map(pdfcraft_content::num).to_vec())); + } + } + unit_ops.push(ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)]))); + unit_ops.push(Op::new("Q", vec![])); let mut serialized = Vec::new(); serialized.push(b'\n'); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9d74024daa..af3c6c3908 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1805,6 +1805,47 @@ fn image_crop_preserves_outer_cm_for_subsequent_drawings() { assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); } +#[test] +fn image_arrange_preserves_position_under_active_ctm() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Move first image to front: must not apply destination CTM twice! + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "first image position preserved"); + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "second image position preserved"); +} + +#[test] +fn image_arrange_preserves_shared_group_clipping_bounds() { + let content = "q 120 120 40 40 re W n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + assert_eq!(initial[1].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + + // Bring image 0 to front: moves it past the external text while preserving clipping bounds + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 0 retains clip"); + assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 1 retains clip"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("External Text"), "external text preserved"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 1b625e1290..1865032206 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -774,9 +774,8 @@ impl crate::PdfCraftApp { { if self.system_clipboard && let Ok(mut cb) = arboard::Clipboard::new() - && cb.get_image().is_ok() { - return true; + return cb.get_image().is_ok(); } } self.last_copied_image.is_some() || self.last_copied_file.is_some() @@ -793,34 +792,39 @@ impl crate::PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] - let os_png = if self.system_clipboard { - if let Ok(mut cb) = arboard::Clipboard::new() - && let Ok(img) = cb.get_image() - { - let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { - *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() - }); - if !matches_internal { - crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes).ok().map(|bytes| { - (img.width as u32, img.height as u32, img.bytes.to_vec(), bytes) - }) - } else { - None - } + let (has_system_cb, os_image_res) = if self.system_clipboard { + if let Ok(mut cb) = arboard::Clipboard::new() { + (true, Some(cb.get_image())) } else { - None + (false, None) } } else { - None + (false, None) }; #[cfg(target_arch = "wasm32")] - let os_png: Option<(u32, u32, Vec, Vec)> = None; + let (has_system_cb, os_image_res): (bool, Option, arboard::Error>>) = (false, None); - let image_data: Option<(String, Vec)> = if let Some((w, h, rgba, png_bytes)) = os_png { - self.last_copied_image = Some((w, h, rgba)); - self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); - self.last_copied_image_rect = None; - Some(("clipboard_image.png".to_string(), png_bytes)) + let image_data: Option<(String, Vec)> = if has_system_cb { + match os_image_res { + Some(Ok(img)) => { + let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { + *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() + }); + if matches_internal && let Some((name, bytes)) = self.last_copied_file.clone() { + Some((name, bytes)) + } else if let Ok(png_bytes) = crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes) { + self.last_copied_image = Some((img.width as u32, img.height as u32, img.bytes.to_vec())); + self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); + self.last_copied_image_rect = None; + Some(("clipboard_image.png".to_string(), png_bytes)) + } else { + None + } + } + // When system clipboard is active and accessible but contains text or non-image data: + // Do NOT fall back to stale in-memory cached image! + _ => None, + } } else if let Some((name, bytes)) = self.last_copied_file.clone() { Some((name, bytes)) } else if let Some((w, h, rgba)) = self.last_copied_image.clone() { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f7630f955d..9055414169 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2717,6 +2717,7 @@ fn image_edge_handle_stretch_and_crop_mode() { h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); h.run_steps(2); h.get_by_label("Remove crop").click(); + h.run_steps(4); let images = h.state().session.get(doc_id).unwrap().page_images(0); assert_eq!(images[0].visible_rect, Some(images[0].rect), "crop is removed"); } @@ -4479,3 +4480,26 @@ fn image_copy_cut_paste_keyboard_and_context_menu() { let after_paste2 = h.state().session.get(id).unwrap().page_images(0).len(); assert_eq!(after_paste2, 2, "pasted via context menu"); } + +#[test] +fn stale_clipboard_plain_text_does_not_paste_image() { + let mut h = harness(1, |_| {}); + h.run_steps(4); + // Put an image into internal cache + h.state_mut().last_copied_image = Some((10, 10, vec![0; 400])); + h.state_mut().last_copied_file = Some(("cached.png".to_string(), vec![1, 2, 3])); + h.state_mut().system_clipboard = true; + + // Set actual system clipboard to plain text + #[cfg(not(target_arch = "wasm32"))] + if let Ok(mut cb) = arboard::Clipboard::new() { + cb.set_text("just plain text").ok(); + } + + // can_paste_image should now be false because the system clipboard has text, not an image! + assert!(!h.state().can_paste_image(), "cannot paste image when system clipboard has text"); + + // paste_image should fail and return false without pasting + assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image"); +} + From 0ca27d3145c7da5c0c20399309c3ed761967c58b Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 21:57:52 +0530 Subject: [PATCH 009/187] Fixes As Requested [WASM Build Failure, Clipping Path Destruction, Graphics State(opacity)] --- crates/edit/src/images.rs | 147 ++++++++++++++++++++++++++----- crates/edit/src/tests.rs | 40 +++++++++ crates/ui-egui/src/content_ui.rs | 23 ++--- 3 files changed, 179 insertions(+), 31 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index b54ecd5bf5..9b73dddac9 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -538,6 +538,109 @@ fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec, + state_ops: Vec, +} + +fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result, EditError> { + if start >= target || target >= ops.len() { + return Ok(None); + } + let mut clip_ops = Vec::new(); + let mut state_ops = Vec::new(); + let mut pending_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut active_path = false; + let mut depth: usize = 0; + let mut matrix = Matrix::IDENTITY; + let mut matrix_at_clip = None; + + let scan_start = if ops.get(start).is_some_and(|o| o.is("q")) { + start.saturating_add(1) + } else { + start + }; + + for i in scan_start..target { + let Some(op) = ops.get(i) else { return Ok(None); }; + match op.op.as_slice() { + b"q" => { + if depth >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + depth = depth.saturating_add(1); + } + b"Q" => { + if depth == 0 { + return Ok(None); + } + depth = depth.saturating_sub(1); + } + _ if depth > 0 => {} + b"cm" => { + if active_path { + return Ok(None); + } + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => { + active_path = true; + pending_path.push(op.clone()); + } + b"W" | b"W*" => { + if !active_path { + return Ok(None); + } + pending_clip = true; + pending_path.push(op.clone()); + } + b"n" => { + if pending_clip { + if matrix_at_clip.is_none() { + matrix_at_clip = Some(matrix); + } else if matrix_at_clip != Some(matrix) { + return Ok(None); + } + clip_ops.extend(std::mem::take(&mut pending_path)); + clip_ops.push(op.clone()); + pending_clip = false; + active_path = false; + } else { + pending_path.clear(); + active_path = false; + } + } + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if pending_clip { + return Ok(None); + } + pending_path.clear(); + active_path = false; + } + b"gs" | b"CS" | b"cs" | b"SC" | b"sc" | b"SCN" | b"scn" | b"G" | b"g" | b"RG" | b"rg" | b"K" | b"k" | b"ri" | b"i" => { + state_ops.push(op.clone()); + } + b"BT" => { + return Ok(None); + } + _ => {} + } + } + + if depth != 0 || active_path || pending_clip { + return Ok(None); + } + + if !clip_ops.is_empty() && matrix_at_clip != Some(Matrix::IDENTITY) { + return Ok(None); + } + + Ok(Some(ExtractedScopeState { clip_ops, state_ops })) +} + /// Change the stacking order of image `index` on `page`. pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: ArrangeOrder) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -649,34 +752,37 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran first_op.span.start..last_op.span.end }; - let is_clipped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); - if has_other_paint && is_clipped && img.clips.len() > 1 { - // Safely decline extraction when complex multi-path clipping cannot be preserved + let scope_start = if has_other_paint { + if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) + && sub_start >= target_unit.start + && sub_end < target_unit.end + { + sub_start + } else { + target_unit.start + } + } else { + target_unit.start + }; + + let is_clipped = (img.visible_rect.is_some() && img.visible_rect != Some(img.rect)) || !img.clips.is_empty(); + + let extracted = extract_scope_state(&ops, scope_start, target)?; + let Some(extracted) = extracted else { + return Ok(()); + }; + + if is_clipped && extracted.clip_ops.is_empty() { return Ok(()); } let mut unit_ops = Vec::new(); unit_ops.push(Op::new("q", vec![])); - if is_clipped { + if !extracted.clip_ops.is_empty() { if dest_ctm != Matrix::IDENTITY { unit_ops.push(Op::new("cm", dest_inv.0.map(pdfcraft_content::num).to_vec())); } - if let Some(clip) = img.visible_rect { - let cx0 = clip[0]; - let cy0 = clip[1]; - let cw = clip[2] - clip[0]; - let ch = clip[3] - clip[1]; - if cw > 0.0 && ch > 0.0 { - unit_ops.push(Op::new("re", vec![ - pdfcraft_content::num(cx0), - pdfcraft_content::num(cy0), - pdfcraft_content::num(cw), - pdfcraft_content::num(ch), - ])); - unit_ops.push(Op::new("W", vec![])); - unit_ops.push(Op::new("n", vec![])); - } - } + unit_ops.extend(extracted.clip_ops); if Matrix(img.draw_matrix) != Matrix::IDENTITY { unit_ops.push(Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec())); } @@ -686,6 +792,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran unit_ops.push(Op::new("cm", local.0.map(pdfcraft_content::num).to_vec())); } } + unit_ops.extend(extracted.state_ops); unit_ops.push(ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)]))); unit_ops.push(Op::new("Q", vec![])); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index af3c6c3908..782cd3acd9 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1846,6 +1846,46 @@ fn image_arrange_preserves_shared_group_clipping_bounds() { assert!(str_content.contains("External Text"), "external text preserved"); } +#[test] +fn image_arrange_preserves_extended_graphics_state_opacity() { + let content = "q /GS0 gs 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "image preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0]); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("/GS0 gs"), "extended graphics state /GS0 gs is retained"); + let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(gs_pos < do_pos, "/GS0 gs must precede /Im0 Do in the new unit"); +} + +#[test] +fn image_arrange_preserves_exact_non_rectangular_clipping_path() { + let content = "q 120 120 m 160 120 l 140 160 l h W* n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("W*"), "even-odd W* fill rule must be preserved"); + assert!(str_content.contains("120 120 m 160 120 l 140 160 l h W* n"), "exact non-rectangular clip path preserved"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 1865032206..70c03d0a1a 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -792,9 +792,10 @@ impl crate::PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] - let (has_system_cb, os_image_res) = if self.system_clipboard { + let (has_system_cb, os_image) = if self.system_clipboard { if let Ok(mut cb) = arboard::Clipboard::new() { - (true, Some(cb.get_image())) + let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec())); + (true, img_opt) } else { (false, None) } @@ -802,18 +803,18 @@ impl crate::PdfCraftApp { (false, None) }; #[cfg(target_arch = "wasm32")] - let (has_system_cb, os_image_res): (bool, Option, arboard::Error>>) = (false, None); + let (has_system_cb, os_image): (bool, Option<(usize, usize, Vec)>) = (false, None); let image_data: Option<(String, Vec)> = if has_system_cb { - match os_image_res { - Some(Ok(img)) => { + match os_image { + Some((width, height, bytes)) => { let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { - *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() + *w as usize == width && *h as usize == height && rgba.as_slice() == bytes.as_slice() }); - if matches_internal && let Some((name, bytes)) = self.last_copied_file.clone() { - Some((name, bytes)) - } else if let Ok(png_bytes) = crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes) { - self.last_copied_image = Some((img.width as u32, img.height as u32, img.bytes.to_vec())); + if matches_internal && let Some((name, file_bytes)) = self.last_copied_file.clone() { + Some((name, file_bytes)) + } else if let Ok(png_bytes) = crate::create_ui::png_from_rgba(width, height, &bytes) { + self.last_copied_image = Some((width as u32, height as u32, bytes)); self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); self.last_copied_image_rect = None; Some(("clipboard_image.png".to_string(), png_bytes)) @@ -823,7 +824,7 @@ impl crate::PdfCraftApp { } // When system clipboard is active and accessible but contains text or non-image data: // Do NOT fall back to stale in-memory cached image! - _ => None, + None => None, } } else if let Some((name, bytes)) = self.last_copied_file.clone() { Some((name, bytes)) From 74eef8cc0f35bf342ac61fd2d5b310e6d5efe156 Mon Sep 17 00:00:00 2001 From: JM Date: Wed, 14 Oct 2026 17:04:46 +0000 Subject: [PATCH 010/187] feat(xtask): add offline licensing self-check --- .github/workflows/ci.yml | 4 +- ATTRIBUTION.md | 372 +++++++------- ATTRIBUTION.toml | 372 +++++++------- LICENSES.toml | 147 ++++++ NOTICE | 2 +- docs/licensing-self-check.md | 33 ++ licenses/CMap-LICENSE.txt | 28 ++ licenses/egui-Hack-Regular.txt | 31 ++ licenses/egui-OFL.txt | 92 ++++ licenses/egui-UFL.txt | 96 ++++ licenses/egui-emoji-icon-font-mit-license.txt | 9 + xtask/src/gates.rs | 2 +- xtask/src/license_check.rs | 467 ++++++++++++++++++ xtask/src/main.rs | 4 +- 14 files changed, 1282 insertions(+), 377 deletions(-) create mode 100644 LICENSES.toml create mode 100644 docs/licensing-self-check.md create mode 100644 licenses/CMap-LICENSE.txt create mode 100644 licenses/egui-Hack-Regular.txt create mode 100644 licenses/egui-OFL.txt create mode 100644 licenses/egui-UFL.txt create mode 100644 licenses/egui-emoji-icon-font-mit-license.txt create mode 100644 xtask/src/license_check.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8249dcf0c7..20a4a9288e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,9 +87,9 @@ jobs: if: runner.os == 'Linux' run: cargo xtask layers - - name: Assets (licences and attribution) + - name: Source licences and attribution if: runner.os == 'Linux' - run: cargo xtask assets + run: cargo xtask license-check - name: CLI without the MCP server still builds if: runner.os == 'Linux' diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3b451f246d..1e3da6e987 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -28,181 +28,181 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths | | `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | @@ -260,23 +260,23 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `docs/images/pdfcraft-tools.png` | PdfCraft screenshot: All tools | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows PdfCraft UI, Lucide icons, the showcase PDF built from the [[fetched]] OFL fonts, and the ArtCraft mark (Storyteller trademark from docs/brand/, not covered by this licence) | | `docs/images/pdfcraft-twoup.png` | PdfCraft screenshot: Two-up reading | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `docs/images/pdfcraft-viewer.png` | PdfCraft screenshot: Viewer with comments | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) | -| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make | -| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) | | `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py | | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format | -| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 07433fc453..4afb3a7b2e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -235,7 +235,7 @@ path = "assets/icons/accessibility.svg" title = "Lucide icon \"accessibility\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -246,7 +246,7 @@ path = "assets/icons/align-left.svg" title = "Lucide icon \"align-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -257,7 +257,7 @@ path = "assets/icons/align-center.svg" title = "Lucide icon \"align-center\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -268,7 +268,7 @@ path = "assets/icons/square-terminal.svg" title = "Lucide icon \"square-terminal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -279,7 +279,7 @@ path = "assets/icons/file-code.svg" title = "Lucide icon \"file-code\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -290,7 +290,7 @@ path = "assets/icons/file-spreadsheet.svg" title = "Lucide icon \"file-spreadsheet\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -301,7 +301,7 @@ path = "assets/icons/align-justify.svg" title = "Lucide icon \"align-justify\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -312,7 +312,7 @@ path = "assets/icons/align-right.svg" title = "Lucide icon \"align-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -323,7 +323,7 @@ path = "assets/icons/columns-3.svg" title = "Lucide icon \"columns-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -334,7 +334,7 @@ path = "assets/icons/rows-3.svg" title = "Lucide icon \"rows-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -345,7 +345,7 @@ path = "assets/icons/flip-horizontal-2.svg" title = "Lucide icon \"flip-horizontal-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -356,7 +356,7 @@ path = "assets/icons/flip-vertical-2.svg" title = "Lucide icon \"flip-vertical-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -367,7 +367,7 @@ path = "assets/icons/app-window.svg" title = "Lucide icon \"app-window\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -378,7 +378,7 @@ path = "assets/icons/arrow-left-right.svg" title = "Lucide icon \"arrow-left-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -389,7 +389,7 @@ path = "assets/icons/arrow-up-down.svg" title = "Lucide icon \"arrow-up-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -400,7 +400,7 @@ path = "assets/icons/arrow-up-right.svg" title = "Lucide icon \"arrow-up-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -411,7 +411,7 @@ path = "assets/icons/badge-check.svg" title = "Lucide icon \"badge-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -422,7 +422,7 @@ path = "assets/icons/bandage.svg" title = "Lucide icon \"bandage\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -433,7 +433,7 @@ path = "assets/icons/bell.svg" title = "Lucide icon \"bell\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -444,7 +444,7 @@ path = "assets/icons/blend.svg" title = "Lucide icon \"blend\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -455,7 +455,7 @@ path = "assets/icons/bold.svg" title = "Lucide icon \"bold\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -466,7 +466,7 @@ path = "assets/icons/book-open.svg" title = "Lucide icon \"book-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -477,7 +477,7 @@ path = "assets/icons/bookmark.svg" title = "Lucide icon \"bookmark\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -488,7 +488,7 @@ path = "assets/icons/bookmark-plus.svg" title = "Lucide icon \"bookmark-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -499,7 +499,7 @@ path = "assets/icons/braces.svg" title = "Lucide icon \"braces\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -510,7 +510,7 @@ path = "assets/icons/brush-cleaning.svg" title = "Lucide icon \"brush-cleaning\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -521,7 +521,7 @@ path = "assets/icons/brush.svg" title = "Lucide icon \"brush\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -532,7 +532,7 @@ path = "assets/icons/camera.svg" title = "Lucide icon \"camera\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -543,7 +543,7 @@ path = "assets/icons/check-circle-2.svg" title = "Lucide icon \"check-circle-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -554,7 +554,7 @@ path = "assets/icons/check.svg" title = "Lucide icon \"check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -565,7 +565,7 @@ path = "assets/icons/chevron-down.svg" title = "Lucide icon \"chevron-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -576,7 +576,7 @@ path = "assets/icons/chevron-left.svg" title = "Lucide icon \"chevron-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -587,7 +587,7 @@ path = "assets/icons/chevron-right.svg" title = "Lucide icon \"chevron-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -598,7 +598,7 @@ path = "assets/icons/chevron-up.svg" title = "Lucide icon \"chevron-up\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -609,7 +609,7 @@ path = "assets/icons/chevrons-left.svg" title = "Lucide icon \"chevrons-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -620,7 +620,7 @@ path = "assets/icons/chevrons-right.svg" title = "Lucide icon \"chevrons-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -631,7 +631,7 @@ path = "assets/icons/circle-check.svg" title = "Lucide icon \"circle-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -642,7 +642,7 @@ path = "assets/icons/circle-dashed.svg" title = "Lucide icon \"circle-dashed\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -653,7 +653,7 @@ path = "assets/icons/circle-dot.svg" title = "Lucide icon \"circle-dot\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -664,7 +664,7 @@ path = "assets/icons/circle-help.svg" title = "Lucide icon \"circle-help\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -675,7 +675,7 @@ path = "assets/icons/circle.svg" title = "Lucide icon \"circle\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -686,7 +686,7 @@ path = "assets/icons/clock-3.svg" title = "Lucide icon \"clock-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -697,7 +697,7 @@ path = "assets/icons/clock.svg" title = "Lucide icon \"clock\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -708,7 +708,7 @@ path = "assets/icons/cloud-upload.svg" title = "Lucide icon \"cloud-upload\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -719,7 +719,7 @@ path = "assets/icons/cloud.svg" title = "Lucide icon \"cloud\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -730,7 +730,7 @@ path = "assets/icons/code-xml.svg" title = "Lucide icon \"code-xml\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -741,7 +741,7 @@ path = "assets/icons/columns-2.svg" title = "Lucide icon \"columns-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -752,7 +752,7 @@ path = "assets/icons/compass.svg" title = "Lucide icon \"compass\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -763,7 +763,7 @@ path = "assets/icons/contrast.svg" title = "Lucide icon \"contrast\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -774,7 +774,7 @@ path = "assets/icons/copy-plus.svg" title = "Lucide icon \"copy-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -785,7 +785,7 @@ path = "assets/icons/copy.svg" title = "Lucide icon \"copy\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -796,7 +796,7 @@ path = "assets/icons/crop.svg" title = "Lucide icon \"crop\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -807,7 +807,7 @@ path = "assets/icons/droplet.svg" title = "Lucide icon \"droplet\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -818,7 +818,7 @@ path = "assets/icons/ellipsis-vertical.svg" title = "Lucide icon \"ellipsis-vertical\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -829,7 +829,7 @@ path = "assets/icons/ellipsis.svg" title = "Lucide icon \"ellipsis\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -840,7 +840,7 @@ path = "assets/icons/eraser.svg" title = "Lucide icon \"eraser\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -851,7 +851,7 @@ path = "assets/icons/external-link.svg" title = "Lucide icon \"external-link\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -862,7 +862,7 @@ path = "assets/icons/eye-off.svg" title = "Lucide icon \"eye-off\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -873,7 +873,7 @@ path = "assets/icons/eye.svg" title = "Lucide icon \"eye\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -884,7 +884,7 @@ path = "assets/icons/file-check.svg" title = "Lucide icon \"file-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -895,7 +895,7 @@ path = "assets/icons/file-down.svg" title = "Lucide icon \"file-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -906,7 +906,7 @@ path = "assets/icons/file-input.svg" title = "Lucide icon \"file-input\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -917,7 +917,7 @@ path = "assets/icons/file-lock-2.svg" title = "Lucide icon \"file-lock-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -928,7 +928,7 @@ path = "assets/icons/file-minus.svg" title = "Lucide icon \"file-minus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -939,7 +939,7 @@ path = "assets/icons/file-output.svg" title = "Lucide icon \"file-output\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -950,7 +950,7 @@ path = "assets/icons/file-pen-line.svg" title = "Lucide icon \"file-pen-line\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -961,7 +961,7 @@ path = "assets/icons/file-plus-2.svg" title = "Lucide icon \"file-plus-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -972,7 +972,7 @@ path = "assets/icons/file-plus.svg" title = "Lucide icon \"file-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -983,7 +983,7 @@ path = "assets/icons/file-scan.svg" title = "Lucide icon \"file-scan\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -994,7 +994,7 @@ path = "assets/icons/file-search.svg" title = "Lucide icon \"file-search\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1005,7 +1005,7 @@ path = "assets/icons/file-symlink.svg" title = "Lucide icon \"file-symlink\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1016,7 +1016,7 @@ path = "assets/icons/file-text.svg" title = "Lucide icon \"file-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1027,7 +1027,7 @@ path = "assets/icons/file-x.svg" title = "Lucide icon \"file-x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1038,7 +1038,7 @@ path = "assets/icons/files.svg" title = "Lucide icon \"files\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1049,7 +1049,7 @@ path = "assets/icons/film.svg" title = "Lucide icon \"film\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1060,7 +1060,7 @@ path = "assets/icons/filter.svg" title = "Lucide icon \"filter\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1071,7 +1071,7 @@ path = "assets/icons/folder-open.svg" title = "Lucide icon \"folder-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1082,7 +1082,7 @@ path = "assets/icons/folder-plus.svg" title = "Lucide icon \"folder-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1093,7 +1093,7 @@ path = "assets/icons/folder.svg" title = "Lucide icon \"folder\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1104,7 +1104,7 @@ path = "assets/icons/gauge.svg" title = "Lucide icon \"gauge\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1115,7 +1115,7 @@ path = "assets/icons/git-compare.svg" title = "Lucide icon \"git-compare\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1126,7 +1126,7 @@ path = "assets/icons/globe.svg" title = "Lucide icon \"globe\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1137,7 +1137,7 @@ path = "assets/icons/grid-3x3.svg" title = "Lucide icon \"grid-3x3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1148,7 +1148,7 @@ path = "assets/icons/hand.svg" title = "Lucide icon \"hand\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1159,7 +1159,7 @@ path = "assets/icons/hash.svg" title = "Lucide icon \"hash\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1170,7 +1170,7 @@ path = "assets/icons/heading.svg" title = "Lucide icon \"heading\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1181,7 +1181,7 @@ path = "assets/icons/highlighter.svg" title = "Lucide icon \"highlighter\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1192,7 +1192,7 @@ path = "assets/icons/house.svg" title = "Lucide icon \"house\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1203,7 +1203,7 @@ path = "assets/icons/image-plus.svg" title = "Lucide icon \"image-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1214,7 +1214,7 @@ path = "assets/icons/image.svg" title = "Lucide icon \"image\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1225,7 +1225,7 @@ path = "assets/icons/info.svg" title = "Lucide icon \"info\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1236,7 +1236,7 @@ path = "assets/icons/italic.svg" title = "Lucide icon \"italic\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1247,7 +1247,7 @@ path = "assets/icons/languages.svg" title = "Lucide icon \"languages\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1258,7 +1258,7 @@ path = "assets/icons/lasso.svg" title = "Lucide icon \"lasso\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1269,7 +1269,7 @@ path = "assets/icons/layers.svg" title = "Lucide icon \"layers\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1280,7 +1280,7 @@ path = "assets/icons/layout-grid.svg" title = "Lucide icon \"layout-grid\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1291,7 +1291,7 @@ path = "assets/icons/link-2.svg" title = "Lucide icon \"link-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1302,7 +1302,7 @@ path = "assets/icons/link.svg" title = "Lucide icon \"link\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1313,7 +1313,7 @@ path = "assets/icons/list-checks.svg" title = "Lucide icon \"list-checks\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1324,7 +1324,7 @@ path = "assets/icons/list-ordered.svg" title = "Lucide icon \"list-ordered\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1335,7 +1335,7 @@ path = "assets/icons/list.svg" title = "Lucide icon \"list\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1346,7 +1346,7 @@ path = "assets/icons/lock-open.svg" title = "Lucide icon \"lock-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1357,7 +1357,7 @@ path = "assets/icons/lock.svg" title = "Lucide icon \"lock\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1368,7 +1368,7 @@ path = "assets/icons/maximize-2.svg" title = "Lucide icon \"maximize-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1379,7 +1379,7 @@ path = "assets/icons/maximize.svg" title = "Lucide icon \"maximize\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1390,7 +1390,7 @@ path = "assets/icons/message-circle-reply.svg" title = "Lucide icon \"message-circle-reply\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1401,7 +1401,7 @@ path = "assets/icons/message-square-text.svg" title = "Lucide icon \"message-square-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1412,7 +1412,7 @@ path = "assets/icons/message-square.svg" title = "Lucide icon \"message-square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1423,7 +1423,7 @@ path = "assets/icons/messages-square.svg" title = "Lucide icon \"messages-square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1434,7 +1434,7 @@ path = "assets/icons/mic.svg" title = "Lucide icon \"mic\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1445,7 +1445,7 @@ path = "assets/icons/minimize.svg" title = "Lucide icon \"minimize\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1456,7 +1456,7 @@ path = "assets/icons/minimize-2.svg" title = "Lucide icon \"minimize-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1467,7 +1467,7 @@ path = "assets/icons/minus.svg" title = "Lucide icon \"minus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1478,7 +1478,7 @@ path = "assets/icons/moon.svg" title = "Lucide icon \"moon\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1489,7 +1489,7 @@ path = "assets/icons/mouse-pointer-2.svg" title = "Lucide icon \"mouse-pointer-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1500,7 +1500,7 @@ path = "assets/icons/move.svg" title = "Lucide icon \"move\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1511,7 +1511,7 @@ path = "assets/icons/navigation.svg" title = "Lucide icon \"navigation\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1522,7 +1522,7 @@ path = "assets/icons/paint-bucket.svg" title = "Lucide icon \"paint-bucket\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1533,7 +1533,7 @@ path = "assets/icons/palette.svg" title = "Lucide icon \"palette\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1544,7 +1544,7 @@ path = "assets/icons/panel-left.svg" title = "Lucide icon \"panel-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1555,7 +1555,7 @@ path = "assets/icons/panel-right.svg" title = "Lucide icon \"panel-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1566,7 +1566,7 @@ path = "assets/icons/panels-top-left.svg" title = "Lucide icon \"panels-top-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1577,7 +1577,7 @@ path = "assets/icons/paperclip.svg" title = "Lucide icon \"paperclip\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1588,7 +1588,7 @@ path = "assets/icons/pen-line.svg" title = "Lucide icon \"pen-line\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1599,7 +1599,7 @@ path = "assets/icons/pen-tool.svg" title = "Lucide icon \"pen-tool\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1610,7 +1610,7 @@ path = "assets/icons/pencil.svg" title = "Lucide icon \"pencil\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1621,7 +1621,7 @@ path = "assets/icons/pipette.svg" title = "Lucide icon \"pipette\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1632,7 +1632,7 @@ path = "assets/icons/plus.svg" title = "Lucide icon \"plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1643,7 +1643,7 @@ path = "assets/icons/presentation.svg" title = "Lucide icon \"presentation\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1654,7 +1654,7 @@ path = "assets/icons/printer.svg" title = "Lucide icon \"printer\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1665,7 +1665,7 @@ path = "assets/icons/rectangle-horizontal.svg" title = "Lucide icon \"rectangle-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1676,7 +1676,7 @@ path = "assets/icons/redo-2.svg" title = "Lucide icon \"redo-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1687,7 +1687,7 @@ path = "assets/icons/replace.svg" title = "Lucide icon \"replace\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1698,7 +1698,7 @@ path = "assets/icons/rotate-ccw.svg" title = "Lucide icon \"rotate-ccw\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1709,7 +1709,7 @@ path = "assets/icons/rotate-cw.svg" title = "Lucide icon \"rotate-cw\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1720,7 +1720,7 @@ path = "assets/icons/ruler.svg" title = "Lucide icon \"ruler\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1731,7 +1731,7 @@ path = "assets/icons/save.svg" title = "Lucide icon \"save\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1742,7 +1742,7 @@ path = "assets/icons/scan-text.svg" title = "Lucide icon \"scan-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1753,7 +1753,7 @@ path = "assets/icons/scan.svg" title = "Lucide icon \"scan\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1764,7 +1764,7 @@ path = "assets/icons/scissors.svg" title = "Lucide icon \"scissors\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1775,7 +1775,7 @@ path = "assets/icons/search.svg" title = "Lucide icon \"search\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1786,7 +1786,7 @@ path = "assets/icons/send.svg" title = "Lucide icon \"send\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1797,7 +1797,7 @@ path = "assets/icons/settings-2.svg" title = "Lucide icon \"settings-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1808,7 +1808,7 @@ path = "assets/icons/settings.svg" title = "Lucide icon \"settings\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1819,7 +1819,7 @@ path = "assets/icons/share-2.svg" title = "Lucide icon \"share-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1830,7 +1830,7 @@ path = "assets/icons/shield-check.svg" title = "Lucide icon \"shield-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1841,7 +1841,7 @@ path = "assets/icons/signature.svg" title = "Lucide icon \"signature\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1852,7 +1852,7 @@ path = "assets/icons/sliders-horizontal.svg" title = "Lucide icon \"sliders-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1863,7 +1863,7 @@ path = "assets/icons/sparkles.svg" title = "Lucide icon \"sparkles\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1874,7 +1874,7 @@ path = "assets/icons/spline.svg" title = "Lucide icon \"spline\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1885,7 +1885,7 @@ path = "assets/icons/square-dashed-mouse-pointer.svg" title = "Lucide icon \"square-dashed-mouse-pointer\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1896,7 +1896,7 @@ path = "assets/icons/square-dashed.svg" title = "Lucide icon \"square-dashed\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1907,7 +1907,7 @@ path = "assets/icons/square.svg" title = "Lucide icon \"square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1918,7 +1918,7 @@ path = "assets/icons/squares-subtract.svg" title = "Lucide icon \"squares-subtract\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1929,7 +1929,7 @@ path = "assets/icons/stamp.svg" title = "Lucide icon \"stamp\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1940,7 +1940,7 @@ path = "assets/icons/star.svg" title = "Lucide icon \"star\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1951,7 +1951,7 @@ path = "assets/icons/sticky-note.svg" title = "Lucide icon \"sticky-note\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1962,7 +1962,7 @@ path = "assets/icons/strikethrough.svg" title = "Lucide icon \"strikethrough\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1973,7 +1973,7 @@ path = "assets/icons/sun.svg" title = "Lucide icon \"sun\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1984,7 +1984,7 @@ path = "assets/icons/swatch-book.svg" title = "Lucide icon \"swatch-book\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1995,7 +1995,7 @@ path = "assets/icons/tag.svg" title = "Lucide icon \"tag\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2006,7 +2006,7 @@ path = "assets/icons/text-cursor-input.svg" title = "Lucide icon \"text-cursor-input\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2017,7 +2017,7 @@ path = "assets/icons/text-select.svg" title = "Lucide icon \"text-select\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2028,7 +2028,7 @@ path = "assets/icons/trash-2.svg" title = "Lucide icon \"trash-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2039,7 +2039,7 @@ path = "assets/icons/trash.svg" title = "Lucide icon \"trash\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2050,7 +2050,7 @@ path = "assets/icons/triangle-alert.svg" title = "Lucide icon \"triangle-alert\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2061,7 +2061,7 @@ path = "assets/icons/type.svg" title = "Lucide icon \"type\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2072,7 +2072,7 @@ path = "assets/icons/underline.svg" title = "Lucide icon \"underline\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2083,7 +2083,7 @@ path = "assets/icons/undo-2.svg" title = "Lucide icon \"undo-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2094,7 +2094,7 @@ path = "assets/icons/user-round.svg" title = "Lucide icon \"user-round\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2105,7 +2105,7 @@ path = "assets/icons/wand-sparkles.svg" title = "Lucide icon \"wand-sparkles\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2116,7 +2116,7 @@ path = "assets/icons/wrench.svg" title = "Lucide icon \"wrench\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2127,7 +2127,7 @@ path = "assets/icons/x.svg" title = "Lucide icon \"x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2138,7 +2138,7 @@ path = "assets/icons/zoom-in.svg" title = "Lucide icon \"zoom-in\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2149,7 +2149,7 @@ path = "assets/icons/zoom-out.svg" title = "Lucide icon \"zoom-out\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3122,7 +3122,7 @@ path = "assets/icons/pin.svg" title = "Lucide icon \"pin\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3133,7 +3133,7 @@ path = "assets/icons/move-right.svg" title = "Lucide icon \"move-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3144,7 +3144,7 @@ path = "assets/icons/reply.svg" title = "Lucide icon \"reply\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3155,7 +3155,7 @@ path = "assets/icons/thumbs-up.svg" title = "Lucide icon \"thumbs-up\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3166,7 +3166,7 @@ path = "assets/icons/thumbs-down.svg" title = "Lucide icon \"thumbs-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3177,7 +3177,7 @@ path = "assets/icons/message-square-plus.svg" title = "Lucide icon \"message-square-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3188,7 +3188,7 @@ path = "assets/icons/circle-x.svg" title = "Lucide icon \"circle-x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3210,7 +3210,7 @@ path = "assets/icons/pentagon.svg" title = "Lucide icon \"pentagon\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3221,7 +3221,7 @@ path = "assets/icons/message-square-quote.svg" title = "Lucide icon \"message-square-quote\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3243,7 +3243,7 @@ path = "assets/icons/clipboard-paste.svg" title = "Lucide icon \"clipboard-paste\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3336,7 +3336,7 @@ path = "assets/icons/monitor.svg" title = "Lucide icon \"monitor\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" diff --git a/LICENSES.toml b/LICENSES.toml new file mode 100644 index 0000000000..1db65150b3 --- /dev/null +++ b/LICENSES.toml @@ -0,0 +1,147 @@ +# Stage 1 source documents only. Destinations are logical names, not packaging instructions. +# licences lists audited full-text identifiers; omission grants no bundle applicability. + +[[document]] +source = "LICENSE-APACHE" +licences = ["Apache-2.0"] +destination = "LICENSE-APACHE" +sha256 = "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594" + +[[document]] +source = "LICENSE-MIT" +licences = ["MIT"] +destination = "LICENSE-MIT" +sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352" + +[[document]] +source = "NOTICE" +destination = "NOTICE" +sha256 = "40c86b3b25c603fcc0ef7918c29caf55906e09c1a9f7e853fe0765e5fe24ca5e" + +[[document]] +source = "assets/app-icon/LICENSE-document.txt" +destination = "assets/app-icon/LICENSE-document.txt" +sha256 = "64a3137e3d5cc41e5b78d2ad8dd9ddddd6588283f9f7b3128aa6a8a50852fb9a" + +[[document]] +source = "assets/app-icon/LICENSE.txt" +destination = "assets/app-icon/LICENSE.txt" +sha256 = "b768acafe6cbad8247eedf86b5b5381feefcd4ee0f8c7808b86811bc687cf37b" + +[[document]] +source = "assets/fonts/OFL-DancingScript.txt" +destination = "assets/fonts/OFL-DancingScript.txt" +sha256 = "6f090277c00af96651ce6dbcc38ff1591047a3bffef486e80b6a32e8276a8201" + +[[document]] +source = "assets/fonts/OFL-Inter.txt" +destination = "assets/fonts/OFL-Inter.txt" +sha256 = "262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a" + +[[document]] +source = "assets/fonts/OFL-JetBrainsMono.txt" +destination = "assets/fonts/OFL-JetBrainsMono.txt" +sha256 = "a76abf002c49097d146e86740a3105a5d00450b1592e820a1109a8c5680cd697" + +[[document]] +source = "assets/icons/LICENSE-lucide.txt" +destination = "assets/icons/LICENSE-lucide.txt" +sha256 = "b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57" + +[[document]] +source = "docs/brand/LICENSE-brand.txt" +destination = "docs/brand/LICENSE-brand.txt" +sha256 = "0f33f48e7a96cd359d4e4e5b11424b13cebeb59cc70c77928186695e4173585c" + +[[document]] +source = "licenses/CMap-LICENSE.txt" +licences = ["BSD-3-Clause"] +destination = "licenses/CMap-LICENSE.txt" +sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29" +origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt" + +[[document]] +source = "licenses/egui-Hack-Regular.txt" +licences = ["MIT", "Bitstream-Vera"] +destination = "licenses/egui-Hack-Regular.txt" +sha256 = "47c0cccbeec7e8614548cc485588b28149e7874188df5f41b36efebcee285c87" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/Hack-Regular.txt" + +[[document]] +source = "licenses/egui-OFL.txt" +licences = ["OFL-1.1"] +destination = "licenses/egui-OFL.txt" +sha256 = "6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/OFL.txt" + +[[document]] +source = "licenses/egui-UFL.txt" +licences = ["Ubuntu-font-1.0"] +destination = "licenses/egui-UFL.txt" +sha256 = "2f0015108d68627bd788d313f529c21ff4da2c2c42a5e1f3883acc83480f9002" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/UFL.txt" + +[[document]] +source = "licenses/egui-emoji-icon-font-mit-license.txt" +licences = ["MIT"] +destination = "licenses/egui-emoji-icon-font-mit-license.txt" +sha256 = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt" + +[[document]] +source = "vendor/hayro-interpret/LICENSE-APACHE" +licences = ["Apache-2.0"] +destination = "vendor/hayro-interpret/LICENSE-APACHE" +sha256 = "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9" + +[[document]] +source = "vendor/hayro-interpret/LICENSE-MIT" +licences = ["MIT"] +destination = "vendor/hayro-interpret/LICENSE-MIT" +sha256 = "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac" + +[[document]] +source = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt" +destination = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt" +sha256 = "469d87b5fa48f8aaf69e25f4fcac3a309c97dbb7d561b917638abdb640e004d9" + +[[document]] +source = "vendor/hayro-interpret/assets/LICENSE_FOXIT" +destination = "vendor/hayro-interpret/assets/LICENSE_FOXIT" +sha256 = "b578cdd2345840ada550bd12519533812320d5f1d21cf4c1c7e1b1b0a31c98b7" + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/Ubuntu-Light.ttf" +licence = "Ubuntu-font-1.0" +documents = ["licenses/egui-UFL.txt"] + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/Hack-Regular.ttf" +licence = "MIT AND Bitstream-Vera" +documents = ["licenses/egui-Hack-Regular.txt"] + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/NotoEmoji-Regular.ttf" +licence = "OFL-1.1" +documents = ["licenses/egui-OFL.txt"] + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/emoji-icon-font.ttf" +licence = "MIT" +documents = ["licenses/egui-emoji-icon-font-mit-license.txt"] + +[[bundled]] +crate = "hayro-cmap" +path = "assets/cmaps.brotli" +licence = "BSD-3-Clause" +documents = ["licenses/CMap-LICENSE.txt"] + +[[bundled]] +crate = "hayro-interpret" +path = "src/font/generated/metrics.rs" +licence = "MIT OR Apache-2.0" +documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"] diff --git a/NOTICE b/NOTICE index 8cacb0b585..5e60d33f40 100644 --- a/NOTICE +++ b/NOTICE @@ -12,7 +12,7 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. -- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt. +- Lucide icons (assets/icons/*.svg) — ISC and MIT licences (Feather-derived icons). See assets/icons/LICENSE-lucide.txt. The Windows PDF file icon (assets/app-icon/pdfcraft-document.svg and .ico) adapts Lucide's file-text outline and combines it with contributor-original artwork. See assets/app-icon/LICENSE-document.txt (ISC AND (MIT OR Apache-2.0)), packaged as diff --git a/docs/licensing-self-check.md b/docs/licensing-self-check.md new file mode 100644 index 0000000000..305d756ff7 --- /dev/null +++ b/docs/licensing-self-check.md @@ -0,0 +1,33 @@ +# Source licensing self-check + +Run `cargo xtask license-check` to validate the checked-out source, offline. The command +does not download assets, fetch models, inspect installers, or generate dependency notices. +It reuses `cargo xtask assets` for attribution, asset policy and locked dependency versions, +then checks the documents recorded in `LICENSES.toml`. + +Each document has a repository-relative source, a unique portable logical destination and +the SHA-256 of its audited text. Destinations are validation metadata, not instructions to +copy files into packages. Required project documents and every asset's `licence_file` must +be mapped. Every dependency-bundled asset must have a document mapping matching its existing +`ATTRIBUTION.toml` licence expression. Foxit has an explicit required document. Licence +texts for egui's default fonts and CMaps are retained in `licenses/`, with their pinned +upstream origins recorded in the manifest. Existing font and asset texts stay in place. + +Missing files, empty or invalid text, changed hashes, duplicate mappings, case-insensitive +destination collisions, parent-file conflicts and unsafe paths fail the check. Source +symlinks must resolve inside the checkout. OCR models need complete attribution and valid +hashes and destination names; the model binaries and downloaded licence texts need not be +present. The optional craft-fonts checkout remains governed by its own attribution and +the existing asset validator; this command does not audit that external checkout. + +When changing attribution, edit `ATTRIBUTION.toml`, run `cargo xtask assets --write`. Generated attribution consistency remains checked only +by the existing asset validator. When changing licence or notice text, re-audit +its provenance before updating the corresponding hash. Do not replace a licence with +guessed terms or update a hash merely to silence a failure. `LICENSES.toml` is validated +with unknown fields rejected. + +The existing Linux source-validation step in `.github/workflows/ci.yml` and `cargo xtask ci` +run `license-check` in place of `assets`; the asset validator runs once within that command. +This Stage 1 check establishes source-document consistency, not completed-package +compliance or legal sufficiency. Installer inspection, dependency-notice generation and +font-delivery packaging changes remain separate work. diff --git a/licenses/CMap-LICENSE.txt b/licenses/CMap-LICENSE.txt new file mode 100644 index 0000000000..2552cc990c --- /dev/null +++ b/licenses/CMap-LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/egui-Hack-Regular.txt b/licenses/egui-Hack-Regular.txt new file mode 100644 index 0000000000..b0ec74488a --- /dev/null +++ b/licenses/egui-Hack-Regular.txt @@ -0,0 +1,31 @@ +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. diff --git a/licenses/egui-OFL.txt b/licenses/egui-OFL.txt new file mode 100644 index 0000000000..d952d62c06 --- /dev/null +++ b/licenses/egui-OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/egui-UFL.txt b/licenses/egui-UFL.txt new file mode 100644 index 0000000000..ae78a8f94e --- /dev/null +++ b/licenses/egui-UFL.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/egui-emoji-icon-font-mit-license.txt b/licenses/egui-emoji-icon-font-mit-license.txt new file mode 100644 index 0000000000..99016e120b --- /dev/null +++ b/licenses/egui-emoji-icon-font-mit-license.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 4406a957c4..0d4ab7db3f 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -102,7 +102,7 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { ("test", Box::new(|| run_args(&["test", "--workspace"]))), ("layers", Box::new(|| layers(&[]))), ("wasm", Box::new(|| wasm(&[]))), - ("assets", Box::new(|| crate::assets::run(&[]))), + ("license-check", Box::new(|| crate::license_check::run(&[]))), ("deny", Box::new(|| deny(&[]))), ("parity", Box::new(|| crate::parity::run(&[]))), ]; diff --git a/xtask/src/license_check.rs b/xtask/src/license_check.rs new file mode 100644 index 0000000000..6b8b8c57fb --- /dev/null +++ b/xtask/src/license_check.rs @@ -0,0 +1,467 @@ +//! Offline source-document checks. This does not inspect completed packages or dependencies. +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +use crate::assets; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Documents { + document: Vec, + bundled: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Document { + source: String, + destination: String, + sha256: String, + /// Audited identifiers of the full licence texts in this document, not inferred from its contents. + #[serde(default)] + licences: Vec, + #[serde(default)] + origin: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Bundled { + #[serde(rename = "crate")] + krate: String, + path: String, + licence: String, + documents: Vec, +} + +pub fn run(args: &[String]) -> Result<()> { + if !args.is_empty() { + bail!("usage: cargo xtask license-check (no downloads or write mode)"); + } + let root = assets::root(); + // Reuse the existing policy and generated-attribution validator, once. + assets::run(&[])?; + validate(&root)?; + println!("license-check: source documents and licensing metadata are consistent"); + Ok(()) +} + +fn validate(root: &Path) -> Result<()> { + let text = std::fs::read_to_string(root.join("LICENSES.toml")).context("reading LICENSES.toml")?; + let docs: Documents = toml::from_str(&text).context("parsing LICENSES.toml")?; + let attribution = assets::load(root)?; + let mut problems = check(root, &docs, &attribution); + let ocr_source = std::fs::read_to_string(root.join("crates/ocr/src/lib.rs")).context("reading OCR model filename constants")?; + problems.extend(check_required_ocr_metadata(&ocr_source, &attribution)?); + if !problems.is_empty() { + bail!("source licensing errors:\n{}", problems.join("\n")); + } + Ok(()) +} + +/// Read only the two filename constants consumed by the OCR application. Fail closed if their +/// declaration format changes; do not guess filenames or add a dependency on the OCR engine. +fn check_required_ocr_metadata(source: &str, attribution: &assets::Manifest) -> Result> { + let mut problems = Vec::new(); + for name in ["DETECTION_MODEL", "RECOGNITION_MODEL"] { + let prefix = format!("pub const {name}: &str = \""); + let declarations: Vec<_> = source.lines().map(str::trim).filter_map(|line| line.strip_prefix(&prefix)).collect(); + let file = declarations.first().and_then(|value| value.strip_suffix("\";")); + let Some(file) = file.filter(|file| declarations.len() == 1 && safe_path(file) && !file.contains('/')) else { + bail!( + "OCR constant {name}: expected one literal filename declaration in crates/ocr/src/lib.rs; update the source validator if its syntax changed" + ); + }; + let entries: Vec<_> = attribution.fetched.iter().filter(|f| f.file == file).collect(); + if entries.is_empty() { + problems.push(format!("{file}: required OCR licensing metadata missing from ATTRIBUTION.toml")); + } else { + if entries.len() != 1 { + problems.push(format!("{file}: required OCR licensing metadata must have exactly one entry")); + } + if entries.iter().any(|f| f.kind != "model") { + problems.push(format!("{file}: required OCR licensing metadata must have kind = \"model\"")); + } + } + } + Ok(problems) +} + +/// The audited bundle inventory currently uses identifiers and flat AND/OR expressions only. +/// Keep their semantics distinct, and fail closed on mixed operators, grouping or exceptions +/// rather than introducing a general SPDX parser or flattening an unsupported expression. +fn licence_coverage(expression: &str, covered: &BTreeSet<&str>) -> Result { + let mut tokens = expression.split_whitespace(); + let Some(first) = tokens.next().filter(|id| assets::ALLOWED_LICENCES.contains(id)) else { + bail!("unsupported bundle licence expression `{expression}`"); + }; + let mut ids = vec![first]; + let mut operator = None; + while let Some(op) = tokens.next() { + if !matches!(op, "AND" | "OR") || operator.is_some_and(|previous| previous != op) { + bail!("unsupported bundle licence expression `{expression}`; audit its applicability before extending the validator"); + } + operator = Some(op); + let Some(id) = tokens.next().filter(|id| assets::ALLOWED_LICENCES.contains(id)) else { + bail!("unsupported bundle licence expression `{expression}`"); + }; + ids.push(id); + } + Ok(if operator == Some("OR") { ids.iter().any(|id| covered.contains(id)) } else { ids.iter().all(|id| covered.contains(id)) }) +} + +// A portable relative path, including on Windows; reject aliases before comparing mappings. +fn safe_path(s: &str) -> bool { + !s.is_empty() + && !s.contains(['\\', ':']) + && s.split('/').all(|p| { + !p.is_empty() + && p != "." + && p != ".." + && !p.ends_with(['.', ' ']) + && p.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + && !matches!( + p.split('.').next().unwrap_or_default().to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) + }) +} + +fn hash(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn check(root: &Path, docs: &Documents, attribution: &assets::Manifest) -> Vec { + let mut problems = Vec::new(); + let mut sources = BTreeSet::new(); + let mut destinations = BTreeSet::new(); + for d in &docs.document { + if !safe_path(&d.source) || !safe_path(&d.destination) { + problems.push(format!("{}: unsafe document source or destination", d.source)); + continue; + } + let new_source = sources.insert(d.source.as_str()); + let new_destination = destinations.insert(d.destination.to_ascii_lowercase()); + if !new_source || !new_destination { + problems.push(format!("{}: duplicate document source or destination", d.source)); + } + if d.origin.as_ref().is_some_and(|u| !u.starts_with("https://") || u.trim().len() <= 8) { + problems.push(format!("{}: invalid document origin", d.source)); + } + let mut identifiers = BTreeSet::new(); + for id in &d.licences { + if !assets::ALLOWED_LICENCES.contains(&id.as_str()) || !identifiers.insert(id.as_str()) { + problems.push(format!("{}: invalid or duplicate audited licence identifier `{id}`", d.source)); + } + } + let path = root.join(&d.source); + // Canonical confinement also catches symlinks escaping the checkout. + let confined = root.canonicalize().and_then(|r| path.canonicalize().map(|p| p.starts_with(r))).unwrap_or(false); + if !confined || !path.is_file() { + problems.push(format!("{}: required document missing or outside checkout", d.source)); + continue; + } + match std::fs::read_to_string(&path) { + Ok(text) if !text.trim().is_empty() && !text.contains('\0') => { + if !hash(&d.sha256) || assets::sha256_hex(text.as_bytes()) != d.sha256 { + problems.push(format!("{}: document hash mismatch; re-audit the source before updating LICENSES.toml", d.source)); + } + } + _ => problems.push(format!("{}: required document empty or invalid UTF-8 text", d.source)), + } + } + for destination in &destinations { + for (i, _) in destination.match_indices('/') { + if destination.get(..i).is_some_and(|prefix| destinations.contains(prefix)) { + problems.push(format!("{destination}: document destination conflicts with a parent file")); + } + } + } + for required in ["LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "vendor/hayro-interpret/assets/LICENSE_FOXIT"] + .into_iter() + .chain(attribution.asset.iter().map(|a| a.licence_file.as_str())) + { + if !sources.contains(required) { + problems.push(format!("{required}: required document has no LICENSES.toml mapping")); + } + } + // One mapping per bundled asset, using the existing attribution's licence expression. + let mut seen = BTreeSet::new(); + for b in &docs.bundled { + if !seen.insert((b.krate.as_str(), b.path.as_str())) { + problems.push(format!("{} {}: duplicate bundled mapping", b.krate, b.path)); + } + if !attribution.bundled.iter().any(|a| a.krate == b.krate && a.path == b.path && a.licence == b.licence) { + problems.push(format!("{} {}: bundled mapping disagrees with ATTRIBUTION.toml", b.krate, b.path)); + } + if b.documents.is_empty() || b.documents.iter().any(|d| !sources.contains(d.as_str())) { + problems.push(format!("{} {}: bundled licence documents missing", b.krate, b.path)); + } + for document in docs.document.iter().filter(|d| b.documents.contains(&d.source)) { + if !document.licences.iter().any(|id| b.licence.split_whitespace().any(|token| token == id)) { + problems.push(format!("{} {}: inapplicable mapped document {}", b.krate, b.path, document.source)); + } + } + let covered: BTreeSet<_> = + docs.document.iter().filter(|d| b.documents.contains(&d.source)).flat_map(|d| d.licences.iter().map(String::as_str)).collect(); + match licence_coverage(&b.licence, &covered) { + Ok(true) => {} + Ok(false) => problems + .push(format!("{} {}: inapplicable licence documents; mapped audited identifiers do not cover `{}`", b.krate, b.path, b.licence)), + Err(error) => problems.push(format!("{} {}: {error}", b.krate, b.path)), + } + } + for b in &attribution.bundled { + if !seen.contains(&(b.krate.as_str(), b.path.as_str())) { + problems.push(format!("{} {}: bundled asset has no licence document mapping", b.krate, b.path)); + } + } + for a in attribution.asset.iter().filter(|a| a.licence_file == "assets/icons/LICENSE-lucide.txt") { + if a.licence != "ISC AND MIT" { + problems.push(format!("{}: Lucide collection must credit ISC and Feather MIT", a.path)); + } + } + let mut model_paths = BTreeSet::new(); + for f in attribution.fetched.iter().filter(|f| f.kind == "model") { + if [f.title.as_str(), &f.author, &f.source, &f.usage, &f.licence].iter().any(|v| v.trim().is_empty()) + || !hash(&f.sha256) + || !hash(&f.licence_sha256) + || !f.url.starts_with("https://") + || !f.licence_url.starts_with("https://") + { + problems.push(format!("{}: incomplete OCR model licensing metadata", f.file)); + } + for path in [f.file.clone(), format!("{}.LICENCE.txt", f.file)] { + if !safe_path(&path) + || path.contains('/') + || path.eq_ignore_ascii_case("ATTRIBUTION.txt") + || !model_paths.insert(path.to_ascii_lowercase()) + { + problems.push(format!("{}: duplicate or unsafe OCR model destination", f.file)); + } + } + } + problems +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn fixture() -> (std::path::PathBuf, Documents) { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!("pdfcraft-license-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&root).unwrap(); + let document = ["LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "vendor/hayro-interpret/assets/LICENSE_FOXIT"] + .into_iter() + .map(|p| { + let path = root.join(p); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "synthetic licence text\n").unwrap(); + Document { + source: p.into(), + destination: p.into(), + sha256: assets::sha256_hex(b"synthetic licence text\n"), + licences: match p { + "LICENSE-MIT" => vec!["MIT".into()], + "LICENSE-APACHE" => vec!["Apache-2.0".into()], + _ => vec![], + }, + origin: None, + } + }) + .collect(); + (root, Documents { document, bundled: vec![] }) + } + + #[test] + fn missing_license_fails_and_restoring_recovers() { + let (root, docs) = fixture(); + let m = assets::Manifest::default(); + assert!(check(&root, &docs, &m).is_empty()); + std::fs::remove_file(root.join("LICENSE-MIT")).unwrap(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("LICENSE-MIT: required document missing"))); + std::fs::write(root.join("LICENSE-MIT"), "synthetic licence text\n").unwrap(); + assert!(check(&root, &docs, &m).is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn empty_invalid_and_changed_documents_fail() { + let (root, docs) = fixture(); + for text in [b" ".as_slice(), b"\xff", b"changed licence", b"text\0"] { + std::fs::write(root.join("NOTICE"), text).unwrap(); + assert!(!check(&root, &docs, &assets::Manifest::default()).is_empty()); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn duplicate_and_unsafe_destinations_fail() { + let (root, mut docs) = fixture(); + for dest in ["license-mit", "../NOTICE", "/NOTICE", "C:/NOTICE", "a\\NOTICE", "a//NOTICE", "CON.txt", "NOTICE."] { + docs.document.get_mut(2).unwrap().destination = dest.into(); + assert!(!check(&root, &docs, &assets::Manifest::default()).is_empty(), "{dest}"); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn removing_required_mapping_fails() { + let (root, mut docs) = fixture(); + docs.document.retain(|d| d.source != "LICENSE-MIT"); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("LICENSE-MIT: required document has no"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bundled_documents_must_match_attribution() { + let (root, mut docs) = fixture(); + let mut m = assets::Manifest::default(); + m.bundled.push(assets::Bundled { + krate: "synthetic".into(), + version: "1.0.0".into(), + path: "font.ttf".into(), + title: "test".into(), + author: "test".into(), + source: "https://example.org".into(), + licence: "MIT".into(), + kind: "font".into(), + usage: "test".into(), + sha256: "a".repeat(64), + adobe_data: false, + }); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("no licence document mapping"))); + docs.bundled.push(Bundled { + krate: "synthetic".into(), + path: "font.ttf".into(), + licence: "MIT".into(), + documents: vec!["LICENSE-MIT".into()], + }); + assert!(check(&root, &docs, &m).is_empty()); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-APACHE".into()]; + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("inapplicable"))); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-MIT".into(), "LICENSE-APACHE".into()]; + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("inapplicable mapped document"))); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-MIT".into()]; + docs.bundled.get_mut(0).unwrap().licence = "ISC".into(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("disagrees"))); + docs.bundled.get_mut(0).unwrap().documents.clear(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("documents missing"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ocr_metadata_and_destinations_are_checked_without_models() { + let (root, docs) = fixture(); + let mut m = assets::Manifest::default(); + m.fetched.push(assets::Fetched { + file: "synthetic.rten".into(), + url: "https://example.org/model".into(), + title: "test model".into(), + author: "test".into(), + source: "https://example.org/card".into(), + licence: "CC-BY-SA-4.0".into(), + licence_url: "https://example.org/licence".into(), + licence_sha256: "a".repeat(64), + kind: "model".into(), + usage: "test".into(), + sha256: "b".repeat(64), + }); + assert!(check(&root, &docs, &m).is_empty()); + m.fetched.get_mut(0).unwrap().licence_sha256 = "z".repeat(64); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("incomplete OCR"))); + m.fetched.get_mut(0).unwrap().file = "../outside.rten".into(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("unsafe OCR"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn required_ocr_models_cannot_be_missing() { + let source = include_str!("../../crates/ocr/src/lib.rs"); + let original = assets::load(&assets::root()).unwrap(); + assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2); + assert!(check_required_ocr_metadata(source, &original).unwrap().is_empty()); + for model in original.fetched.iter().filter(|f| f.kind == "model") { + let mut m = assets::load(&assets::root()).unwrap(); + m.fetched.retain(|f| f.file != model.file); + assert!(check_required_ocr_metadata(source, &m).unwrap().iter().any(|p| p.contains(&model.file) && p.contains("missing"))); + } + } + + #[test] + fn required_ocr_models_cannot_be_misclassified() { + let source = include_str!("../../crates/ocr/src/lib.rs"); + let original = assets::load(&assets::root()).unwrap(); + assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2); + for model in original.fetched.iter().filter(|f| f.kind == "model") { + let mut m = assets::load(&assets::root()).unwrap(); + m.fetched.iter_mut().find(|f| f.file == model.file).unwrap().kind = "font".into(); + assert!(check_required_ocr_metadata(source, &m).unwrap().iter().any(|p| p.contains(&model.file) && p.contains("kind"))); + } + } + + #[test] + fn licence_alternatives_and_combined_obligations_are_distinct() { + let mit = BTreeSet::from(["MIT"]); + assert!(licence_coverage("MIT OR Apache-2.0", &mit).unwrap()); + assert!(!licence_coverage("MIT AND Apache-2.0", &mit).unwrap()); + let both = BTreeSet::from(["MIT", "Bitstream-Vera"]); + assert!(licence_coverage("MIT AND Bitstream-Vera", &both).unwrap()); + assert!(!licence_coverage("MIT OR Apache-2.0", &BTreeSet::from(["OFL-1.1"])).unwrap()); + for expression in ["", "MIT AND", "MIT OR Apache-2.0 AND ISC", "(MIT OR Apache-2.0)", "MIT WITH exception"] { + assert!(licence_coverage(expression, &both).is_err(), "{expression}"); + } + } + + #[test] + fn changed_ocr_constant_syntax_fails_closed() { + assert!(check_required_ocr_metadata("", &assets::Manifest::default()).is_err()); + } + + #[test] + fn parent_file_destination_conflicts_fail() { + let (root, mut docs) = fixture(); + docs.document.get_mut(2).unwrap().destination = "LICENSE-MIT/NOTICE".into(); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("parent file"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn symlink_escape_fails() { + let (root, docs) = fixture(); + let outside = root.with_extension("outside"); + std::fs::write(&outside, "synthetic licence text\n").unwrap(); + std::fs::remove_file(root.join("LICENSE-MIT")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("LICENSE-MIT")).unwrap(); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("outside checkout"))); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_file(outside).unwrap(); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 70741d22b7..240eb7294a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod demo_pdf; mod fuzz; mod gates; mod layers; +mod license_check; mod macos_integration; mod parity; mod screenshots; @@ -21,7 +22,8 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("layers", "Enforce the crate dependency layering (plan/architecture.md §3)", gates::layers), ("wasm", "cargo check --target wasm32-unknown-unknown for every crate below L8", gates::wasm), ("deny", "Dependency licences, bans, sources and advisories (deny.toml; needs cargo-deny)", gates::deny), - ("ci", "fmt --check, clippy -D warnings, test, layers, wasm, assets, deny, parity (stops at first failure)", gates::ci), + ("ci", "fmt --check, clippy -D warnings, test, layers, wasm, license-check, deny, parity (stops at first failure)", gates::ci), + ("license-check", "Offline source licence documents, attribution and metadata", license_check::run), ("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run), ("corpus", "Fetch the pinned pdf.js test corpus into corpus/ (git-ignored) and verify it; --update-pin re-records the pin", gates::corpus), ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), From 8bd57468e892fbd9fe220e1b5ac6ceec0a17ce19 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 22:36:44 +0530 Subject: [PATCH 011/187] Fixes As Requested [Some images not moving, Opacity Mistakes] --- crates/edit/src/images.rs | 110 ++++++++++++++++++------- crates/edit/src/tests.rs | 164 ++++++++++++++++++++++++++++++++++---- 2 files changed, 228 insertions(+), 46 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 9b73dddac9..311f17a4a2 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -543,10 +543,45 @@ struct ExtractedScopeState { state_ops: Vec, } +#[derive(Clone, Default)] +struct ExtendedGStateTracker { + stack: Vec>, + current_gs: Option, +} + +impl ExtendedGStateTracker { + fn step(&mut self, op: &Op) -> Result<(), EditError> { + match op.op.as_slice() { + b"q" => { + if self.stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push(self.current_gs.clone()); + } + b"Q" => { + if let Some(prev) = self.stack.pop() { + self.current_gs = prev; + } + } + b"gs" => { + self.current_gs = Some(op.clone()); + } + _ => {} + } + Ok(()) + } +} + fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result, EditError> { - if start >= target || target >= ops.len() { + if target >= ops.len() || start > target { return Ok(None); } + if start == target { + return Ok(Some(ExtractedScopeState { + clip_ops: Vec::new(), + state_ops: Vec::new(), + })); + } let mut clip_ops = Vec::new(); let mut state_ops = Vec::new(); let mut pending_path: Vec = Vec::new(); @@ -725,55 +760,68 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran }; let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut tracker = ExtendedGStateTracker::default(); + let mut source_effective_gs = None; + let mut dest_effective_gs = None; + let mut dest_ctm = Matrix::IDENTITY; + + let max_scan = scan_target.max(target.saturating_add(1)); for (i, op) in ops.iter().enumerate() { + if i == target { + source_effective_gs = tracker.current_gs.clone(); + } if i == scan_target { + dest_effective_gs = tracker.current_gs.clone(); + dest_ctm = state.placement.matrix; + } + if i >= max_scan { break; } state.apply(op)?; + tracker.step(op)?; + } + if scan_target >= ops.len() && dest_effective_gs.is_none() { + dest_effective_gs = tracker.current_gs.clone(); + dest_ctm = state.placement.matrix; } - let dest_ctm = state.placement.matrix; let Some(dest_inv) = dest_ctm.invert() else { return Ok(()); }; - let target_span = if has_other_paint { - if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) - && sub_start >= target_unit.start - && sub_end < target_unit.end - { - let start_op = ops.get(sub_start).unwrap_or(&ops[target]); - let end_op = ops.get(sub_end).unwrap_or(&ops[target]); - start_op.span.start..end_op.span.end - } else { - let target_op = &ops[target]; - target_op.span.start..target_op.span.end - } + let (target_span, scope_start) = if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) + && sub_start >= target_unit.start + && sub_end < target_unit.end + { + let start_op = ops.get(sub_start).unwrap_or(&ops[target]); + let end_op = ops.get(sub_end).unwrap_or(&ops[target]); + (start_op.span.start..end_op.span.end, sub_start) + } else if has_other_paint { + let target_op = &ops[target]; + (target_op.span.start..target_op.span.end, target_unit.start) } else { let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - first_op.span.start..last_op.span.end - }; - - let scope_start = if has_other_paint { - if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) - && sub_start >= target_unit.start - && sub_end < target_unit.end - { - sub_start - } else { - target_unit.start - } - } else { - target_unit.start + (first_op.span.start..last_op.span.end, target_unit.start) }; let is_clipped = (img.visible_rect.is_some() && img.visible_rect != Some(img.rect)) || !img.clips.is_empty(); let extracted = extract_scope_state(&ops, scope_start, target)?; - let Some(extracted) = extracted else { - return Ok(()); + let Some(mut extracted) = extracted else { + return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into())); }; if is_clipped && extracted.clip_ops.is_empty() { - return Ok(()); + return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into())); + } + + if source_effective_gs.is_none() && dest_effective_gs.is_some() { + return Err(EditError::Invalid("unsupported graphics state: moving an opaque image into an active transparency scope is not supported".into())); + } + + if let Some(src_gs) = source_effective_gs { + let already_has_gs = extracted.state_ops.iter().any(|o| o.is("gs")); + if !already_has_gs { + extracted.state_ops.push(src_gs); + } } let mut unit_ops = Vec::new(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 782cd3acd9..dae7d30411 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1817,10 +1817,22 @@ fn image_arrange_preserves_position_under_active_ctm() { // Move first image to front: must not apply destination CTM twice! images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); + + // 1. Position: check order of operators in the content stream + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let first_do = str_content.find("/Im0 Do").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(first_do < last_do, "Position: multiple image Do calls exist and order was updated"); + + // 2. Appearance: both images' positions and sizes are preserved let after = images::page_images(&doc, 0).unwrap(); assert_eq!(after.len(), 2); - assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "first image position preserved"); - assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "second image position preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: first image position preserved"); + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: second image position preserved"); + + // 3. Preservation: outer CTM is preserved + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: initial CTM preserved"); } #[test] @@ -1837,13 +1849,23 @@ fn image_arrange_preserves_shared_group_clipping_bounds() { // Bring image 0 to front: moves it past the external text while preserving clipping bounds images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); - let after = images::page_images(&doc, 0).unwrap(); - assert_eq!(after.len(), 2, "both images preserved"); - assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 0 retains clip"); - assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 1 retains clip"); + + // 1. Position: moved image Do now follows External Text in AST let bytes = page_content_bytes(&doc, 0); let str_content = String::from_utf8_lossy(&bytes); - assert!(str_content.contains("External Text"), "external text preserved"); + let text_pos = str_content.find("External Text").unwrap(); + let moved_do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < moved_do_pos, "Position: moved image Do follows External Text"); + + // 2. Appearance: both images retain exact clipping bounds and geometry + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 0 retains clip"); + assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 1 retains clip"); + + // 3. Preservation: External Text and original group drawing preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); + assert!(str_content.contains("120 120 40 40 re W n"), "Preservation: clipping path operators preserved"); } #[test] @@ -1856,16 +1878,24 @@ fn image_arrange_preserves_extended_graphics_state_opacity() { images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); - let after = images::page_images(&doc, 0).unwrap(); - assert_eq!(after.len(), 1, "image preserved"); - assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0]); + // 1. Position: /Im0 Do moved past External Text let bytes = page_content_bytes(&doc, 0); let str_content = String::from_utf8_lossy(&bytes); - assert!(str_content.contains("/GS0 gs"), "extended graphics state /GS0 gs is retained"); - let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + let text_pos = str_content.find("External Text").unwrap(); let do_pos = str_content.rfind("/Im0 Do").unwrap(); - assert!(gs_pos < do_pos, "/GS0 gs must precede /Im0 Do in the new unit"); + assert!(text_pos < do_pos, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: /GS0 gs is retained and directly precedes /Im0 Do + let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + assert!(text_pos < gs_pos, "Appearance: /GS0 gs is in the new moved unit"); + assert!(gs_pos < do_pos, "Appearance: /GS0 gs directly precedes /Im0 Do"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "image preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: text preserved"); } #[test] @@ -1877,13 +1907,117 @@ fn image_arrange_preserves_exact_non_rectangular_clipping_path() { images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); + + // 1. Position: moved image follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < last_do, "Position: moved image follows External Text"); + + // 2. Appearance: polygon geometry and even-odd rule are preserved + assert!(str_content.contains("W*"), "Appearance: even-odd W* fill rule preserved"); + assert!(str_content.contains("120 120 m 160 120 l 140 160 l h W* n"), "Appearance: exact polygon clip preserved"); let after = images::page_images(&doc, 0).unwrap(); assert_eq!(after.len(), 2, "both images preserved"); + // 3. Preservation: External Text and remaining original group content preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); +} + +#[test] +fn unwrapped_image_moves_successfully() { + let content = "100 0 0 100 50 50 cm /Im0 Do BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes Middle Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("Middle Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes Middle Text"); + + // Move unwrapped image to front (past Middle Text) + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows Middle Text in the content stream AST let bytes = page_content_bytes(&doc, 0); let str_content = String::from_utf8_lossy(&bytes); - assert!(str_content.contains("W*"), "even-odd W* fill rule must be preserved"); - assert!(str_content.contains("120 120 m 160 120 l 140 160 l h W* n"), "exact non-rectangular clip path preserved"); + let after_text = str_content.find("Middle Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do actually moved after Middle Text"); + + // 2. Appearance: image rect and visibility are unchanged + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: image coordinates preserved"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + // 3. Preservation: Middle Text and CTM remain intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"), "Preservation: text operation preserved"); + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: original cm preserved"); +} + +#[test] +fn inherited_source_opacity_is_preserved() { + // Outer q sets /GS0 gs; inner q draws /Im0 Do; outer Q restores state before External Text + let content = "q /GS0 gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes External Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("External Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes External Text"); + + // Move image to front (past External Text, outside its original outer q...Q scope) + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: effective appearance state (/GS0 gs) is injected and precedes /Im0 Do in the new unit + let after_gs = str_content.rfind("/GS0 gs").unwrap(); + assert!(after_text < after_gs, "Appearance: /GS0 gs injected into the new unit at destination"); + assert!(after_gs < after_do, "Appearance: /GS0 gs directly precedes /Im0 Do in the moved unit"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text and original outer scope structure are preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_opacity_pollution_is_prevented() { + // /Im0 Do is fully opaque at the start; destination has active top-level /GS_translucent gs + let content = "100 0 0 100 50 50 cm /Im0 Do /GS_translucent gs BT /F1 12 Tf 72 700 Td (Translucent Text) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move opaque image 0 to front into active transparency scope must safely bail out with Err + let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently corrupting opacity"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: the document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); } From 8be74fdd16f06b16ddb8d9e9a25e495279037f21 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 23:18:24 +0530 Subject: [PATCH 012/187] Fixes As Requested [GS state, Pollution Guard]t --- crates/edit/src/images.rs | 132 ++++++++++++++++++++++++++++++++------ crates/edit/src/tests.rs | 56 +++++++++++++++- 2 files changed, 167 insertions(+), 21 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 311f17a4a2..56d6ea56d7 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -543,28 +543,99 @@ struct ExtractedScopeState { state_ops: Vec, } +#[derive(Clone, Debug, Default, PartialEq)] +struct EffectiveGState { + ca: Option, + ca_stroke: Option, + bm: Option>, + smask: Option>, +} + +impl EffectiveGState { + fn effective_ca(&self) -> f64 { + self.ca.unwrap_or(1.0) + } + + fn effective_ca_stroke(&self) -> f64 { + self.ca_stroke.unwrap_or(1.0) + } + + fn effective_bm(&self) -> &[u8] { + self.bm.as_deref().unwrap_or(b"Normal") + } + + fn effective_smask(&self) -> Option<&[u8]> { + match self.smask.as_deref() { + Some(b"None") | None => None, + Some(other) => Some(other), + } + } + + fn visually_differs(&self, other: &EffectiveGState) -> bool { + (self.effective_ca() - other.effective_ca()).abs() > 1e-4 + || (self.effective_ca_stroke() - other.effective_ca_stroke()).abs() > 1e-4 + || self.effective_bm() != other.effective_bm() + || self.effective_smask() != other.effective_smask() + } + + fn merge_dict(&mut self, dict: &Dict, doc: &Document) { + if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca = Some(val); + } + if let Some(val) = dict.get(b"CA").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca_stroke = Some(val); + } + if let Some(bm_obj) = dict.get(b"BM").map(|v| doc.resolve(v)) { + if let Some(name) = bm_obj.as_name() { + self.bm = Some(name.to_vec()); + } else if let Some(arr) = bm_obj.as_array() + && let Some(first_name) = arr.first().map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(|n| n.to_vec())) + { + self.bm = Some(first_name); + } + } + if let Some(smask_obj) = dict.get(b"SMask").map(|v| doc.resolve(v)) { + if let Some(name) = smask_obj.as_name() { + self.smask = Some(name.to_vec()); + } else if smask_obj.as_dict().is_some() { + self.smask = Some(b"CustomSMask".to_vec()); + } + } + } +} + #[derive(Clone, Default)] struct ExtendedGStateTracker { - stack: Vec>, - current_gs: Option, + stack: Vec<(EffectiveGState, Vec)>, + current_state: EffectiveGState, + active_gs_ops: Vec, } impl ExtendedGStateTracker { - fn step(&mut self, op: &Op) -> Result<(), EditError> { + fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document) -> Result<(), EditError> { match op.op.as_slice() { b"q" => { if self.stack.len() >= 256 { return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); } - self.stack.push(self.current_gs.clone()); + self.stack.push((self.current_state.clone(), self.active_gs_ops.clone())); } b"Q" => { - if let Some(prev) = self.stack.pop() { - self.current_gs = prev; + if let Some((prev_state, prev_ops)) = self.stack.pop() { + self.current_state = prev_state; + self.active_gs_ops = prev_ops; } } b"gs" => { - self.current_gs = Some(op.clone()); + if let Some(name) = op.name(0) { + if let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) { + self.current_state.merge_dict(&dict, doc); + } else if self.current_state.ca.is_none() { + self.current_state.ca = Some(0.5); + } + self.active_gs_ops.retain(|o| o.name(0) != Some(name)); + self.active_gs_ops.push(op.clone()); + } } _ => {} } @@ -759,29 +830,34 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran (op.span.start, dest_unit.start) }; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut tracker = ExtendedGStateTracker::default(); - let mut source_effective_gs = None; - let mut dest_effective_gs = None; + let mut source_effective_state = EffectiveGState::default(); + let mut source_gs_ops = Vec::new(); + let mut dest_effective_state = EffectiveGState::default(); let mut dest_ctm = Matrix::IDENTITY; let max_scan = scan_target.max(target.saturating_add(1)); for (i, op) in ops.iter().enumerate() { if i == target { - source_effective_gs = tracker.current_gs.clone(); + source_effective_state = tracker.current_state.clone(); + source_gs_ops = tracker.active_gs_ops.clone(); } if i == scan_target { - dest_effective_gs = tracker.current_gs.clone(); + dest_effective_state = tracker.current_state.clone(); dest_ctm = state.placement.matrix; } if i >= max_scan { break; } state.apply(op)?; - tracker.step(op)?; + tracker.step(op, &ext_gstates, doc)?; } - if scan_target >= ops.len() && dest_effective_gs.is_none() { - dest_effective_gs = tracker.current_gs.clone(); + if scan_target >= ops.len() { + dest_effective_state = tracker.current_state.clone(); dest_ctm = state.placement.matrix; } let Some(dest_inv) = dest_ctm.invert() else { return Ok(()); }; @@ -813,17 +889,33 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into())); } - if source_effective_gs.is_none() && dest_effective_gs.is_some() { - return Err(EditError::Invalid("unsupported graphics state: moving an opaque image into an active transparency scope is not supported".into())); + for src_gs in &source_gs_ops { + let name = src_gs.name(0); + let already_present = extracted.state_ops.iter().any(|o| o.name(0) == name); + if !already_present { + extracted.state_ops.push(src_gs.clone()); + } } - if let Some(src_gs) = source_effective_gs { - let already_has_gs = extracted.state_ops.iter().any(|o| o.is("gs")); - if !already_has_gs { - extracted.state_ops.push(src_gs); + let mut reconstructed_state = dest_effective_state.clone(); + for op in &extracted.state_ops { + if op.is("gs") + && let Some(name) = op.name(0) + { + if let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) { + reconstructed_state.merge_dict(&dict, doc); + } else if reconstructed_state.ca.is_none() { + reconstructed_state.ca = Some(0.5); + } } } + if reconstructed_state.visually_differs(&source_effective_state) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image would alter effective opacity, blend mode, or soft mask".into(), + )); + } + let mut unit_ops = Vec::new(); unit_ops.push(Op::new("q", vec![])); if !extracted.clip_ops.is_empty() { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index dae7d30411..7cf9242cc2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1563,7 +1563,7 @@ fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), @@ -2020,6 +2020,60 @@ fn destination_opacity_pollution_is_prevented() { assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); } +#[test] +fn sequential_gs_operators_merging_properties_are_preserved() { + // Sequential gs calls: /GSalpha sets ca=0.6, /GSblend sets BM=/Multiply (without touching ca). + let content = "q /GSalpha gs /GSblend gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: Both merged state operators (/GSalpha and /GSblend) are preserved and precede /Im0 Do + let after_alpha = str_content.rfind("/GSalpha gs").unwrap(); + let after_blend = str_content.rfind("/GSblend gs").unwrap(); + assert!(after_text < after_alpha, "Appearance: /GSalpha gs injected into moved unit"); + assert!(after_text < after_blend, "Appearance: /GSblend gs injected into moved unit"); + assert!(after_alpha < after_do, "Appearance: /GSalpha gs precedes /Im0 Do"); + assert!(after_blend < after_do, "Appearance: /GSblend gs precedes /Im0 Do"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_differing_blend_mode_pollution_is_prevented() { + // /Im0 Do has default Normal blend mode; destination has active top-level /GSblend gs (BM = /Multiply) + let content = "100 0 0 100 50 50 cm /Im0 Do /GSblend gs BT /F1 12 Tf 72 700 Td (Blended Section) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move Normal-blend image to front into active Multiply-blend scope must safely bail out with Err + let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently altering blend mode"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { From 60925a85b69999aecb34100728cc15ee8ee03539 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Thu, 15 Oct 2026 00:24:17 +0530 Subject: [PATCH 013/187] fix: address reviewer feedback regarding [GS operator related] --- Cargo.lock | 1 + crates/edit/Cargo.toml | 1 + crates/edit/src/images.rs | 83 ++++++++++++++++++++++++++-------- crates/edit/src/lib.rs | 2 +- crates/edit/src/tests.rs | 95 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df58c57d21..52485e1e32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4027,6 +4027,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", "thiserror 2.0.21", "unicode-bidi", ] diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 4eb2d6132b..f04f5ba17e 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -22,3 +22,4 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } +pdfcraft-render = { workspace = true } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 56d6ea56d7..fdc62b026f 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -10,6 +10,27 @@ use std::sync::Arc; use crate::EditError; +/// The identity of a soft mask (`/SMask`) in the PDF graphics state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MaskIdentity { + /// Indirect reference to a mask dictionary or stream. + Ref(u32, u16), + /// Indirect reference to the transparency group (`/G`) in a mask dictionary. + Group(u32, u16), + /// Explicit named mask or resource name. + Named(Vec), +} + +impl std::fmt::Display for MaskIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MaskIdentity::Ref(num, generation) => write!(f, "{num} {generation} R"), + MaskIdentity::Group(num, generation) => write!(f, "group {num} {generation} R"), + MaskIdentity::Named(name) => write!(f, "{}", String::from_utf8_lossy(name)), + } + } +} + /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] pub struct PageImage { @@ -30,6 +51,8 @@ pub struct PageImage { /// Pixel size; zero for vector/grouped Form artwork. pub width: u32, pub height: u32, + /// Active soft mask identity at the image placement, if any. + pub smask: Option, stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. @@ -213,12 +236,16 @@ impl ImageState { pub fn page_images(doc: &Document, page: usize) -> Result, EditError> { let p = page_of(doc, page)?; let xo = xobjects(doc, &p.dict); + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); // The graphics state carries over from one stream to the next. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut tracker = ExtendedGStateTracker::default(); for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { for (i, op) in parse(&data).ops.iter().enumerate() { state.apply(op)?; + tracker.step(op, &ext_gstates, doc)?; let ctm = state.placement.matrix; if op.is("Do") { let Some(name) = op.name(0) else { continue }; @@ -274,6 +301,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr is_form, width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + smask: tracker.current_state.smask.clone(), stream: si, op: i, draw_matrix: ctm.0, @@ -347,6 +375,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi is_form: false, width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, + smask: None, stream, op: i, draw_matrix: ctm.0, @@ -548,7 +577,7 @@ struct EffectiveGState { ca: Option, ca_stroke: Option, bm: Option>, - smask: Option>, + smask: Option, } impl EffectiveGState { @@ -564,11 +593,8 @@ impl EffectiveGState { self.bm.as_deref().unwrap_or(b"Normal") } - fn effective_smask(&self) -> Option<&[u8]> { - match self.smask.as_deref() { - Some(b"None") | None => None, - Some(other) => Some(other), - } + fn effective_smask(&self) -> Option<&MaskIdentity> { + self.smask.as_ref() } fn visually_differs(&self, other: &EffectiveGState) -> bool { @@ -578,7 +604,7 @@ impl EffectiveGState { || self.effective_smask() != other.effective_smask() } - fn merge_dict(&mut self, dict: &Dict, doc: &Document) { + fn merge_dict(&mut self, dict: &Dict, doc: &Document, gs_name: Option<&[u8]>) { if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { self.ca = Some(val); } @@ -594,11 +620,28 @@ impl EffectiveGState { self.bm = Some(first_name); } } - if let Some(smask_obj) = dict.get(b"SMask").map(|v| doc.resolve(v)) { - if let Some(name) = smask_obj.as_name() { - self.smask = Some(name.to_vec()); - } else if smask_obj.as_dict().is_some() { - self.smask = Some(b"CustomSMask".to_vec()); + if let Some(raw_smask) = dict.get(b"SMask") { + let resolved = doc.resolve(raw_smask); + if let Some(name) = resolved.as_name() { + if name == b"None" { + self.smask = None; + } else { + self.smask = Some(MaskIdentity::Named(name.to_vec())); + } + } else if let Some(mask_dict) = resolved.as_dict() { + if let Some(gr) = mask_dict.get(b"G").and_then(|g| g.as_ref()) { + self.smask = Some(MaskIdentity::Group(gr.num, gr.generation)); + } else if let Some(r) = raw_smask.as_ref() { + self.smask = Some(MaskIdentity::Ref(r.num, r.generation)); + } else if let Some(name) = mask_dict.get(b"Name").map(|n| doc.resolve(n)).and_then(|n| n.as_name().map(|s| s.to_vec())) { + self.smask = Some(MaskIdentity::Named(name)); + } else if let Some(name) = gs_name { + self.smask = Some(MaskIdentity::Named(name.to_vec())); + } else { + self.smask = Some(MaskIdentity::Named(b"CustomMask".to_vec())); + } + } else if let Some(r) = raw_smask.as_ref() { + self.smask = Some(MaskIdentity::Ref(r.num, r.generation)); } } } @@ -615,7 +658,7 @@ impl ExtendedGStateTracker { fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document) -> Result<(), EditError> { match op.op.as_slice() { b"q" => { - if self.stack.len() >= 256 { + if self.stack.len() >= 4096 { return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); } self.stack.push((self.current_state.clone(), self.active_gs_ops.clone())); @@ -629,7 +672,7 @@ impl ExtendedGStateTracker { b"gs" => { if let Some(name) = op.name(0) { if let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) { - self.current_state.merge_dict(&dict, doc); + self.current_state.merge_dict(&dict, doc, Some(name)); } else if self.current_state.ca.is_none() { self.current_state.ca = Some(0.5); } @@ -672,7 +715,7 @@ fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result