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/289] 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 69f80819e1a17e8d0a58cd09fe8f1d6726c6fd95 Mon Sep 17 00:00:00 2001
From: Philip
Date: Mon, 12 Oct 2026 21:04:06 +0100
Subject: [PATCH 002/289] M2: draw non-embedded CJK CID fonts with an installed
face
A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that the PDF
doesn't embed drew nothing unless craft-fonts had a Japanese face: text boxes
from Acrobat (Kozuka Gothic) and Pages (SongtiTC) were blank. After craft-fonts,
the renderer now uses one face already installed on the machine, read at
runtime and never bundled. The face of a multi-region collection (PingFang SC
or TC) is chosen by region. Noto CJK and Source Han are not candidates, and
PDFCRAFT_SYSTEM_FONTS=0 turns it off.
Co-Authored-By: Claude Sonnet 5.5
---
ROADMAP.md | 1 +
crates/render/Cargo.toml | 4 +-
crates/render/src/lib.rs | 1 +
crates/render/src/raster.rs | 61 +++++++-
crates/render/src/system_font.rs | 248 +++++++++++++++++++++++++++++++
5 files changed, 312 insertions(+), 3 deletions(-)
create mode 100644 crates/render/src/system_font.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index 44a26604d8..b8fc5d6956 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -141,6 +141,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing.
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml
index 28202a38b4..71467dfeb2 100644
--- a/crates/render/Cargo.toml
+++ b/crates/render/Cargo.toml
@@ -13,6 +13,8 @@ pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
hayro = { workspace = true }
hayro-syntax = { workspace = true }
+# Picks the face of an installed font collection (the version pdfcraft-fonts already uses).
+skrifa = "0.44"
lopdf = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
@@ -27,7 +29,5 @@ bytemuck = { version = "1" }
getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
-# Checks the Japanese substitute's character coverage; the version pdfcraft-fonts already uses.
-skrifa = "0.44"
# Builds the decompression-bomb streams in the raster tests.
flate2 = { workspace = true }
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index d2ee5712c4..90bc64b086 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -13,6 +13,7 @@ mod pixels;
mod raster;
mod reading;
mod structure;
+mod system_font;
pub mod text;
pub use inspect::{
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index 0e64ac241e..fe469958ae 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -73,7 +73,7 @@ impl RenderConfig {
InterpreterSettings {
ocg_overrides: self.layers.clone(),
hide_comments: self.hide_comments,
- font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| standard(query))),
+ font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| system_fallback(query)).or_else(|| standard(query))),
..InterpreterSettings::default()
}
}
@@ -106,6 +106,14 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
Some((Arc::new(face.bytes), 0))
}
+/// An installed face for a CID font of an Adobe CJK collection that the PDF doesn't embed and
+/// craft-fonts has no face for (Traditional and Simplified Chinese, Korean, and Japanese in a
+/// build without craft-fonts). `None` for every other font.
+fn system_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
+ let FontQuery::Fallback(f) = query else { return None };
+ crate::system_font::fallback(f)
+}
+
#[derive(Debug, PartialEq)]
enum JapaneseFace {
Mincho,
@@ -5515,6 +5523,57 @@ trailer << /Root 1 0 R >>
}
}
+ /// Chinese and Korean CID fonts that aren't embedded (Adobe-CNS1 `MSung-Light`, Adobe-GB1
+ /// `STSong-Light`, Adobe-Korea1 `HYGoThic-Medium`; Acrobat also writes Japanese `KozGoPr6N`
+ /// text boxes without the font) drew nothing, because craft-fonts has only Japanese faces.
+ /// They now draw in a face already installed on the machine, read at runtime. A machine
+ /// without such a face draws nothing, as before, so each check is skipped there.
+ #[test]
+ fn non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face() {
+ use hayro::hayro_interpret::font::FallbackFontQuery;
+ use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
+ // (base font, collection, ordering, encoding, UCS-2 code units of the text)
+ let cases = [
+ ("MSung-Light", CidFamily::AdobeCNS1, "CNS1", "UniCNS-UCS2-H", "7E419AD45B57"), // 繁體字
+ ("STSong-Light", CidFamily::AdobeGB1, "GB1", "UniGB-UCS2-H", "72698D448DF7"), // 物资跷 (common Simplified)
+ ("HYGoThic-Medium", CidFamily::AdobeKorea1, "Korea1", "UniKS-UCS2-H", "D55CAE00"), // 한글
+ ];
+ for (base_font, family, ordering, encoding, code) in cases {
+ let query = FontQuery::Fallback(FallbackFontQuery {
+ post_script_name: Some(base_font.into()),
+ character_collection: Some(CharacterCollection { family, supplement: 2 }),
+ ..FallbackFontQuery::default()
+ });
+ if super::system_fallback(&query).is_none() {
+ eprintln!("no installed face for {base_font} (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
+ continue;
+ }
+ let content = format!("BT /F1 40 Tf 5 15 Td <{code}> Tj ET");
+ let pdf = format!(
+ "%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
+4 0 obj << /Length {} >> stream
+{content}
+endstream endobj
+5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /{encoding} /DescendantFonts [6 0 R] >> endobj
+6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering ({ordering}) /Supplement 2 >>
+ /FontDescriptor 7 0 R /DW 1000 >> endobj
+7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
+trailer << /Root 1 0 R >>
+%%EOF",
+ content.len()
+ );
+ let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(p.error.is_none(), "{base_font}: {:?}", p.error);
+ let inked = p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
+ // Two to four glyphs at 40 pt cover several hundred dark pixels; a blank run has none.
+ assert!(inked > 300, "{base_font}: the text is drawn ({inked} dark pixels)");
+ }
+ }
+
/// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font
/// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them
/// (Shippori Mincho) draws them as .notdef.
diff --git a/crates/render/src/system_font.rs b/crates/render/src/system_font.rs
new file mode 100644
index 0000000000..d2c98bb7cd
--- /dev/null
+++ b/crates/render/src/system_font.rs
@@ -0,0 +1,248 @@
+//! Installed CJK faces for CID fonts a PDF doesn't embed.
+//!
+//! Acrobat and other tools often write Chinese, Japanese or Korean text with one of the Adobe
+//! collections (Adobe-Japan1, GB1, CNS1, Korea1) and a font name such as `KozGoPr6N-Medium` or
+//! `MSung-Light` without embedding the font. hayro maps those CIDs to Unicode itself, but it needs
+//! a face to draw them with; without one the text is blank. The craft-fonts Japanese faces come
+//! first (see `japanese_fallback` in `raster.rs`); this module is the last resort: one face
+//! already installed on this machine. It is read at runtime and never embedded or shipped
+//! (AGENTS.md §1.4), Noto CJK and Source Han are left out on purpose (§1.1), and
+//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off, as for the interface font.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, PoisonError};
+
+use hayro::hayro_interpret::font::{FallbackFontQuery, FontData};
+use hayro::hayro_interpret::hayro_cmap::CidFamily;
+use skrifa::MetadataProvider as _;
+use skrifa::string::StringId;
+
+/// Larger files are not read: a font path is still untrusted input. CJK system faces are big
+/// collections (PingFang is tens of megabytes).
+const MAX_BYTES: u64 = 96 << 20;
+/// Faces tried in a collection (`.ttc`).
+const MAX_FACES: u32 = 32;
+
+/// The writing system a character collection is for.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+enum Script {
+ Japanese,
+ SimplifiedChinese,
+ TraditionalChinese,
+ Korean,
+}
+
+impl Script {
+ fn of(family: &CidFamily) -> Option {
+ match family {
+ CidFamily::AdobeJapan1 => Some(Self::Japanese),
+ CidFamily::AdobeGB1 => Some(Self::SimplifiedChinese),
+ CidFamily::AdobeCNS1 => Some(Self::TraditionalChinese),
+ CidFamily::AdobeKorea1 => Some(Self::Korean),
+ CidFamily::AdobeIdentity | CidFamily::Custom { .. } => None,
+ }
+ }
+
+ /// A character the face must have to be worth using. Each is common and not shared by the
+ /// other scripts' faces: 物 (Japanese; also Chinese, so it only rules out Latin-only faces),
+ /// 欢 (Simplified only), 說 (Traditional only), 한 (Hangul).
+ fn probe(self) -> char {
+ match self {
+ Self::Japanese => '\u{7269}',
+ Self::SimplifiedChinese => '\u{6B22}',
+ Self::TraditionalChinese => '\u{8AAA}',
+ Self::Korean => '\u{D55C}',
+ }
+ }
+}
+
+/// One installed file and the words (in a face's family name) that pick the face for the right
+/// region out of a collection that holds several (PingFang SC, TC and HK share one file).
+struct Candidate {
+ path: PathBuf,
+ region: &'static [&'static str],
+}
+
+/// The installed face to draw a CID font of `query`'s collection with, as `(data, face index)`.
+/// `None` when it is turned off, the collection isn't one of the four, or nothing fits.
+pub(crate) fn fallback(query: &FallbackFontQuery) -> Option<(FontData, u32)> {
+ if cfg!(target_arch = "wasm32") || std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
+ return None;
+ }
+ let script = Script::of(&query.character_collection.as_ref()?.family)?;
+ let bold = query.is_bold || query.font_weight >= 600;
+ /// A face file and the index of the chosen face in it, or `None` when nothing fits.
+ type Face = Option<(Arc>, u32)>;
+ static CACHE: Mutex>> = Mutex::new(None);
+ let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
+ let found = cache.get_or_insert_with(HashMap::new).entry((script, bold)).or_insert_with(|| {
+ candidates(script)
+ .iter()
+ .find_map(|c| read(c, script.probe(), bold))
+ .inspect(|(_, index)| log::info!("CJK font fallback for {script:?}: face {index}"))
+ });
+ found.as_ref().map(|(bytes, index)| (Arc::clone(bytes) as FontData, *index))
+}
+
+fn read(candidate: &Candidate, probe: char, bold: bool) -> Option<(Arc>, u32)> {
+ let meta = std::fs::metadata(&candidate.path).ok()?;
+ if !meta.is_file() || meta.len() > MAX_BYTES {
+ return None;
+ }
+ let bytes = std::fs::read(&candidate.path).ok()?;
+ let index = pick_face(&bytes, probe, candidate.region, bold)?;
+ Some((Arc::new(bytes), index))
+}
+
+/// The best face of the file that parses and maps `probe`: the region's face when the file has
+/// one, then the regular (or, for bold text, the semibold or bold) weight, then the first.
+fn pick_face(bytes: &[u8], probe: char, region: &[&str], bold: bool) -> Option {
+ let mut best: Option<(u32, u32)> = None;
+ for index in 0..MAX_FACES {
+ let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { continue };
+ if font.charmap().map(probe).is_none() {
+ continue;
+ }
+ let names = |id: StringId| font.localized_strings(id).map(|s| s.chars().collect::()).collect::>();
+ let family = names(StringId::FAMILY_NAME).join(" ");
+ let style = names(StringId::SUBFAMILY_NAME).join(" ").to_ascii_lowercase();
+ let mut score = 1;
+ if region.iter().any(|r| family.split_whitespace().any(|w| w == *r)) {
+ score += 4;
+ }
+ let weighty = style.contains("bold") || style.contains("semibold") || style.contains("heavy");
+ let plain = style.is_empty() || style.contains("regular") || style.contains("normal") || style.contains("medium") && !bold;
+ if (bold && weighty) || (!bold && plain && !weighty) {
+ score += 2;
+ }
+ if best.is_none_or(|(_, s)| score > s) {
+ best = Some((index, score));
+ }
+ }
+ best.map(|(index, _)| index)
+}
+
+fn candidates(script: Script) -> Vec {
+ let region: &'static [&'static str] = match script {
+ Script::Japanese => &["JP", "Japanese"],
+ Script::SimplifiedChinese => &["SC", "GB"],
+ Script::TraditionalChinese => &["TC", "HK", "CNS"],
+ Script::Korean => &["KR", "Korean"],
+ };
+ let files: Vec = if cfg!(windows) {
+ let dir = windows_fonts();
+ let names: &[&str] = match script {
+ Script::Japanese => &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"],
+ Script::SimplifiedChinese => &["msyh.ttc", "simsun.ttc", "msjh.ttc"],
+ Script::TraditionalChinese => &["msjh.ttc", "mingliu.ttc", "msyh.ttc"],
+ Script::Korean => &["malgun.ttf", "gulim.ttc"],
+ };
+ names.iter().map(|f| dir.join(f)).collect()
+ } else if cfg!(target_os = "macos") {
+ let names: &[&str] = match script {
+ Script::Japanese => &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans GB.ttc", "PingFang.ttc"],
+ Script::SimplifiedChinese => &["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc"],
+ Script::TraditionalChinese => &["PingFang.ttc", "STHeiti Light.ttc", "Hiragino Sans GB.ttc"],
+ Script::Korean => &["AppleSDGothicNeo.ttc", "AppleGothic.ttf", "PingFang.ttc"],
+ };
+ names.iter().map(|f| Path::new("/System/Library/Fonts").join(f)).collect()
+ } else {
+ let names: &[&str] = match script {
+ Script::Korean => {
+ &["truetype/nanum/NanumGothic.ttf", "nanum/NanumGothic.ttf", "truetype/baekmuk/gulim.ttf", "truetype/unfonts-core/UnDotum.ttf"]
+ }
+ Script::Japanese => &[
+ "truetype/fonts-japanese-gothic.ttf",
+ "ipa-gothic/ipag.ttf",
+ "truetype/takao-gothic/TakaoGothic.ttf",
+ "truetype/vlgothic/VL-Gothic-Regular.ttf",
+ ],
+ Script::SimplifiedChinese | Script::TraditionalChinese => &[
+ "truetype/wqy/wqy-microhei.ttc",
+ "wenquanyi/wqy-microhei/wqy-microhei.ttc",
+ "wqy-microhei/wqy-microhei.ttc",
+ "truetype/wqy/wqy-zenhei.ttc",
+ "wenquanyi/wqy-zenhei/wqy-zenhei.ttc",
+ "wqy-zenhei/wqy-zenhei.ttc",
+ "truetype/droid/DroidSansFallbackFull.ttf",
+ "google-droid-sans-fonts/DroidSansFallbackFull.ttf",
+ "truetype/arphic/uming.ttc",
+ "arphic-uming/uming.ttc",
+ ],
+ };
+ ["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| names.iter().map(move |f| Path::new(dir).join(f))).collect()
+ };
+ files.into_iter().map(|path| Candidate { path, region }).collect()
+}
+
+fn windows_fonts() -> PathBuf {
+ let dir = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
+ dir.join("Fonts")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SCRIPTS: [Script; 4] = [Script::Japanese, Script::SimplifiedChinese, Script::TraditionalChinese, Script::Korean];
+
+ #[test]
+ fn broken_and_missing_files_are_skipped() {
+ assert_eq!(pick_face(b"", '\u{7269}', &[], false), None);
+ assert_eq!(pick_face(b"not a font at all", '\u{7269}', &[], false), None);
+ assert_eq!(pick_face(&[0u8; 4096], '\u{7269}', &[], false), None);
+ let missing = Candidate { path: PathBuf::from("definitely/not/here.ttc"), region: &[] };
+ assert!(read(&missing, '\u{7269}', false).is_none());
+ // A directory is not a font.
+ let dir = Candidate { path: std::env::temp_dir(), region: &[] };
+ assert!(read(&dir, '\u{7269}', false).is_none());
+ }
+
+ #[test]
+ fn a_latin_face_is_rejected_for_every_script() {
+ let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
+ for script in SCRIPTS {
+ assert_eq!(pick_face(inter, script.probe(), &[], false), None, "{script:?}");
+ }
+ assert_eq!(pick_face(inter, 'A', &[], false), Some(0));
+ }
+
+ #[test]
+ fn candidates_are_absolute_font_files_without_adobe_designs() {
+ for script in SCRIPTS {
+ let list = candidates(script);
+ assert!(!list.is_empty(), "{script:?}");
+ for c in list {
+ assert!(c.path.is_absolute(), "{}", c.path.display());
+ assert!(c.path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{}", c.path.display());
+ let name = c.path.to_string_lossy().to_lowercase();
+ assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}");
+ }
+ }
+ }
+
+ #[test]
+ fn only_the_four_adobe_collections_have_a_script() {
+ assert_eq!(Script::of(&CidFamily::AdobeCNS1), Some(Script::TraditionalChinese));
+ assert_eq!(Script::of(&CidFamily::AdobeGB1), Some(Script::SimplifiedChinese));
+ assert_eq!(Script::of(&CidFamily::AdobeKorea1), Some(Script::Korean));
+ assert_eq!(Script::of(&CidFamily::AdobeJapan1), Some(Script::Japanese));
+ assert_eq!(Script::of(&CidFamily::AdobeIdentity), None);
+ }
+
+ /// On a Mac the face for Traditional Chinese is the TC one of PingFang, not the first of the
+ /// collection (which is another region's), and it maps a character only Traditional has.
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn macos_picks_the_regional_pingfang_face() {
+ let path = Path::new("/System/Library/Fonts/PingFang.ttc");
+ let Ok(bytes) = std::fs::read(path) else { return };
+ for (script, region) in [(Script::TraditionalChinese, "TC"), (Script::SimplifiedChinese, "SC")] {
+ let index = pick_face(&bytes, script.probe(), &[region], false).expect("PingFang covers it");
+ let font = skrifa::FontRef::from_index(&bytes, index).unwrap();
+ let family: String = font.localized_strings(StringId::FAMILY_NAME).next().unwrap().chars().collect();
+ assert!(family.contains(region), "{script:?} got {family:?}");
+ }
+ }
+}
From b680ded337354487b26ff56c27b0986e21e0dfcb Mon Sep 17 00:00:00 2001
From: JM
Date: Tue, 13 Oct 2026 15:38:04 +0000
Subject: [PATCH 003/289] 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 004/289] 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 005/289] 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 006/289] 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 007/289] 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 008/289] 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 009/289] 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 010/289] 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 011/289] 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 012/289] 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 013/289] 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 014/289] 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 {
- if depth >= 256 {
+ if depth >= 4096 {
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
}
depth = depth.saturating_add(1);
@@ -889,13 +932,17 @@ 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()));
}
+ let mut outer_gs_ops = Vec::new();
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());
+ outer_gs_ops.push(src_gs.clone());
}
}
+ let mut combined_state_ops = outer_gs_ops;
+ combined_state_ops.extend(extracted.state_ops);
+ extracted.state_ops = combined_state_ops;
let mut reconstructed_state = dest_effective_state.clone();
for op in &extracted.state_ops {
@@ -903,7 +950,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
&& 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);
+ reconstructed_state.merge_dict(&dict, doc, Some(name));
} else if reconstructed_state.ca.is_none() {
reconstructed_state.ca = Some(0.5);
}
@@ -918,6 +965,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
let mut unit_ops = Vec::new();
unit_ops.push(Op::new("q", vec![]));
+ unit_ops.extend(extracted.state_ops);
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()));
@@ -932,7 +980,6 @@ 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/lib.rs b/crates/edit/src/lib.rs
index 59dc804a66..d6809240c5 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::{ArrangeOrder, ImageChange, PageImage, arrange_image, change_image, crop_image, page_images, reading_images, rect_to_rect, remove_image_crop, turn_about_centre};
+pub use images::{ArrangeOrder, ImageChange, MaskIdentity, 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 7cf9242cc2..0a2a50ede6 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -2074,6 +2074,101 @@ fn destination_differing_blend_mode_pollution_is_prevented() {
assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified");
}
+fn nested_soft_mask_page(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 100 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /GroupA 9 0 R /GroupB 10 0 R >> /ExtGState << /GSA 7 0 R /GSB 8 0 R >> >> >>".into(),
+ format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
+ "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(),
+ "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(),
+ "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 10 0 R >> >>".into(),
+ "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(),
+ "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 50 100 re f\nendstream".into(),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offs = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offs.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let x = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offs {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
+ Document::open(Arc::new(out)).unwrap()
+}
+
+#[test]
+fn nested_soft_masks_replay_in_correct_order_and_effective_mask_is_rendered() {
+ let content = "q /GSA gs q /GSB gs 100 0 0 100 0 0 cm /Im0 Do Q Q BT /F1 12 Tf 10 90 Td (External Text) Tj ET";
+ let mut doc = nested_soft_mask_page(content);
+ let initial = images::page_images(&doc, 0).unwrap();
+ assert_eq!(initial.len(), 1);
+ assert_eq!(initial[0].smask, Some(images::MaskIdentity::Group(10, 0)));
+
+ images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
+
+ let content_bytes = page_content_bytes(&doc, 0);
+ let str_content = String::from_utf8_lossy(&content_bytes);
+
+ // 1. Assert emitted stream order: outer /GSA gs must precede inner /GSB gs, which precedes /Im0 Do
+ let after_text = str_content.find("External Text").unwrap();
+ let after_a = str_content.rfind("/GSA gs").unwrap();
+ let after_b = str_content.rfind("/GSB gs").unwrap();
+ let after_do = str_content.rfind("/Im0 Do").unwrap();
+ assert!(after_text < after_a, "Position: /GSA gs relocated past external text");
+ assert!(after_a < after_b, "Order: outer /GSA gs must be replayed before inner /GSB gs");
+ assert!(after_b < after_do, "Order: inner /GSB gs must immediately precede /Im0 Do");
+
+ // 2. Assert effective mask upon saving and reopening is correctly identified as B
+ let reopened_doc = reopen(&doc);
+ let reopened_images = images::page_images(&reopened_doc, 0).unwrap();
+ assert_eq!(reopened_images.len(), 1);
+ assert_eq!(reopened_images[0].smask, Some(images::MaskIdentity::Group(10, 0)));
+
+ // 3. Rendered-pixel assertion: render output and verify Mask B (left 50x100) is effective, not Mask A (bottom 100x50)
+ let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default());
+ let page = renderer.render(pdfcraft_render::RenderRequest {
+ page: 0,
+ kind: pdfcraft_render::RequestKind::Pixels,
+ scale: 1.0,
+ ..Default::default()
+ });
+ let px = |x: u32, y: u32| {
+ let idx = ((y * page.width + x) * 4) as usize;
+ page.rgba[idx..idx + 4].to_vec()
+ };
+ // Left half (inside Mask B): image is rendered black
+ assert_eq!(px(25, 25), vec![0, 0, 0, 255], "point (25, 25) is inside Mask B: image rendered");
+ assert_eq!(px(25, 75), vec![0, 0, 0, 255], "point (25, 75) is inside Mask B: image rendered");
+ // Right half (outside Mask B): masked out to white page background
+ assert_eq!(px(75, 25), vec![255, 255, 255, 255], "point (75, 25) is outside Mask B: masked out to white");
+ assert_eq!(px(75, 75), vec![255, 255, 255, 255], "point (75, 75) is outside Mask B: masked out to white");
+}
+
+#[test]
+fn destination_differing_mask_pollution_is_prevented() {
+ // /Im0 Do has no mask; destination has active top-level /GSA gs (Mask A)
+ let content = "100 0 0 100 0 0 cm /Im0 Do /GSA gs BT /F1 12 Tf 10 90 Td (Masked Section) Tj ET";
+ let mut doc = nested_soft_mask_page(content);
+ let initial_bytes = page_content_bytes(&doc, 0);
+
+ let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
+ assert!(res.is_err(), "must return an explicit Err rather than corrupting mask state");
+ 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 9a02fc64018214fe73357461aa342366dfe932e6 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Thu, 15 Oct 2026 01:04:43 +0530
Subject: [PATCH 015/289] fix: address reviewer feedback regarding [GS operator
related]
---
crates/edit/src/images.rs | 81 ++++++++++++++++++-----------
crates/edit/src/tests.rs | 107 ++++++++++++++++++++++++++++++++++++++
2 files changed, 158 insertions(+), 30 deletions(-)
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index fdc62b026f..5fcc4ae422 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -245,7 +245,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr
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)?;
+ tracker.step(op, &ext_gstates, doc, state.placement.matrix)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
@@ -649,13 +649,13 @@ impl EffectiveGState {
#[derive(Clone, Default)]
struct ExtendedGStateTracker {
- stack: Vec<(EffectiveGState, Vec)>,
+ stack: Vec<(EffectiveGState, Vec<(Op, Matrix)>)>,
current_state: EffectiveGState,
- active_gs_ops: Vec,
+ active_gs_ops: Vec<(Op, Matrix)>,
}
impl ExtendedGStateTracker {
- fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document) -> Result<(), EditError> {
+ fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document, ctm: Matrix) -> Result<(), EditError> {
match op.op.as_slice() {
b"q" => {
if self.stack.len() >= 4096 {
@@ -676,8 +676,8 @@ impl ExtendedGStateTracker {
} 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());
+ self.active_gs_ops.retain(|(o, _)| o.name(0) != Some(name));
+ self.active_gs_ops.push((op.clone(), ctm));
}
}
_ => {}
@@ -897,13 +897,15 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
break;
}
state.apply(op)?;
- tracker.step(op, &ext_gstates, doc)?;
+ tracker.step(op, &ext_gstates, doc, state.placement.matrix)?;
}
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(()); };
+ if dest_ctm.invert().is_none() {
+ return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into()));
+ }
let (target_span, scope_start) = if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target)
&& sub_start >= target_unit.start
@@ -924,7 +926,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
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(mut extracted) = extracted else {
+ let Some(extracted) = extracted else {
return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()));
};
@@ -932,23 +934,11 @@ 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()));
}
- let mut outer_gs_ops = Vec::new();
- 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 {
- outer_gs_ops.push(src_gs.clone());
- }
- }
- let mut combined_state_ops = outer_gs_ops;
- combined_state_ops.extend(extracted.state_ops);
- extracted.state_ops = combined_state_ops;
+ let non_gs_ops: Vec = extracted.state_ops.into_iter().filter(|o| !o.is("gs")).collect();
let mut reconstructed_state = dest_effective_state.clone();
- for op in &extracted.state_ops {
- if op.is("gs")
- && let Some(name) = op.name(0)
- {
+ for (op, _) in &source_gs_ops {
+ 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()) {
reconstructed_state.merge_dict(&dict, doc, Some(name));
} else if reconstructed_state.ca.is_none() {
@@ -965,19 +955,50 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
let mut unit_ops = Vec::new();
unit_ops.push(Op::new("q", vec![]));
- unit_ops.extend(extracted.state_ops);
+ let mut current_ctm = dest_ctm;
+
+ for (gs_op, target_ctm) in &source_gs_ops {
+ if *target_ctm != current_ctm {
+ let Some(current_inv) = current_ctm.invert() else {
+ return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into()));
+ };
+ let delta = target_ctm.then(¤t_inv);
+ if !delta.0.iter().all(|v| v.is_finite()) {
+ return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into()));
+ }
+ if delta != Matrix::IDENTITY {
+ unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec()));
+ current_ctm = *target_ctm;
+ }
+ }
+ unit_ops.push(gs_op.clone());
+ }
+
+ unit_ops.extend(non_gs_ops);
+
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 current_ctm != Matrix::IDENTITY {
+ let Some(current_inv) = current_ctm.invert() else {
+ return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into()));
+ };
+ unit_ops.push(Op::new("cm", current_inv.0.map(pdfcraft_content::num).to_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()));
}
} else {
- 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()));
+ if Matrix(img.draw_matrix) != current_ctm {
+ let Some(current_inv) = current_ctm.invert() else {
+ return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into()));
+ };
+ let delta = Matrix(img.draw_matrix).then(¤t_inv);
+ if !delta.0.iter().all(|v| v.is_finite()) {
+ return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into()));
+ }
+ if delta != Matrix::IDENTITY {
+ unit_ops.push(Op::new("cm", delta.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)])));
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 0a2a50ede6..b46b35740d 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -2169,6 +2169,113 @@ fn destination_differing_mask_pollution_is_prevented() {
assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified");
}
+fn soft_mask_with_alpha_page(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 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /GroupA 9 0 R >> /ExtGState << /GSA 7 0 R >> >> >>".into(),
+ format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
+ "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(),
+ "<< /Type /ExtGState /ca 0.25 /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(),
+ "<< /Type /ExtGState /SMask /None >>".into(),
+ "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offs = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offs.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let x = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offs {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
+ Document::open(Arc::new(out)).unwrap()
+}
+
+#[test]
+fn non_identity_cm_before_gs_preserves_mask_coordinates_and_rendering() {
+ let content = "q 2 0 0 2 10 10 cm /GSA gs 50 0 0 50 0 0 cm /Im0 Do Q BT /F1 12 Tf 10 180 Td (External Text) Tj ET";
+ let mut doc = soft_mask_with_alpha_page(content);
+
+ // Initial state: image bounds are [10, 10, 110, 110]
+ let initial_images = images::page_images(&doc, 0).unwrap();
+ assert_eq!(initial_images.len(), 1);
+ assert!(close(initial_images[0].rect, [10.0, 10.0, 110.0, 110.0]));
+
+ // Render baseline before reordering
+ let pdf_bytes_before = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_before), pdfcraft_render::RenderConfig::default());
+ let page_before = renderer_before.render(pdfcraft_render::RenderRequest {
+ page: 0,
+ kind: pdfcraft_render::RequestKind::Pixels,
+ scale: 1.0,
+ ..Default::default()
+ });
+ let px_before = |x: u32, y: u32| {
+ let idx = ((y * page_before.width + x) * 4) as usize;
+ page_before.rgba[idx..idx + 4].to_vec()
+ };
+
+ // Arrange image: BringToFront
+ images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
+
+ let content_bytes = page_content_bytes(&doc, 0);
+ let str_content = String::from_utf8_lossy(&content_bytes);
+
+ // 1. Assert AST reordering succeeds and interleaves cm before gs
+ let text_pos = str_content.find("External Text").unwrap();
+ let cm_mask_pos = str_content.rfind("2 0 0 2 10 10 cm").unwrap();
+ let gs_pos = str_content.rfind("/GSA gs").unwrap();
+ let cm_img_pos = str_content.rfind("50 0 0 50 0 0 cm").unwrap();
+ let do_pos = str_content.rfind("/Im0 Do").unwrap();
+
+ assert!(text_pos < cm_mask_pos, "Image relocated after external text");
+ assert!(cm_mask_pos < gs_pos, "CTM for soft mask established before /GSA gs");
+ assert!(gs_pos < cm_img_pos, "/GSA gs executed before image cm");
+ assert!(cm_img_pos < do_pos, "image cm executed before /Im0 Do");
+
+ // 2. Assert image bounds remain unchanged after saving and reopening
+ let reopened_doc = reopen(&doc);
+ let reopened_images = images::page_images(&reopened_doc, 0).unwrap();
+ assert_eq!(reopened_images.len(), 1);
+ assert!(
+ close(reopened_images[0].rect, [10.0, 10.0, 110.0, 110.0]),
+ "Image bounds preserved: {:?}",
+ reopened_images[0].rect
+ );
+
+ // 3. Render reopened document and assert non-overlapping pixels remain unchanged
+ let pdf_bytes_after = write_incremental(&reopened_doc, &SaveOptions::default()).unwrap();
+ let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_after), pdfcraft_render::RenderConfig::default());
+ let page_after = renderer_after.render(pdfcraft_render::RenderRequest {
+ page: 0,
+ kind: pdfcraft_render::RequestKind::Pixels,
+ scale: 1.0,
+ ..Default::default()
+ });
+ let px_after = |x: u32, y: u32| {
+ let idx = ((y * page_after.width + x) * 4) as usize;
+ page_after.rgba[idx..idx + 4].to_vec()
+ };
+
+ // A point inside the masked image (e.g. at x=50, inside the image and bottom half of mask)
+ // Non-overlapping with text (text is at y=180).
+ // The pixel renders at [191, 191, 191, 255] (25% black over 100% white).
+ // In raster coordinates, raster y for PDF y=50 in a 200-high page is y=150.
+ let rendered_pixel_before = px_before(50, 150);
+ let rendered_pixel_after = px_after(50, 150);
+ assert_eq!(rendered_pixel_before, vec![191, 191, 191, 255], "baseline pixel rendered with 25% opacity mask");
+ assert_eq!(rendered_pixel_after, rendered_pixel_before, "rendered non-overlapping pixels remain unchanged");
+
+ // Outside the image: pure white background
+ assert_eq!(px_after(5, 5), vec![255, 255, 255, 255]);
+ assert_eq!(px_after(5, 5), px_before(5, 5));
+}
+
#[test]
fn form_artwork_edits_only_the_selected_placement() {
From daa6286466688fc5bbcb410ba20c7dc217d98a2f Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Thu, 15 Oct 2026 01:31:40 +0530
Subject: [PATCH 016/289] chore: run cargo fmt to satisfy formatting gate
---
crates/edit/src/images.rs | 22 ++++++----------------
crates/edit/src/lib.rs | 5 ++++-
crates/edit/src/tests.rs | 15 +++------------
3 files changed, 13 insertions(+), 29 deletions(-)
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index 5fcc4ae422..725900e2bb 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -691,10 +691,7 @@ fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result Result {
if depth >= 4096 {
@@ -1235,12 +1230,7 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64
{
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("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 {
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index d6809240c5..822a626666 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -623,7 +623,10 @@ 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::{ArrangeOrder, ImageChange, MaskIdentity, PageImage, arrange_image, change_image, crop_image, page_images, reading_images, rect_to_rect, remove_image_crop, turn_about_centre};
+pub use images::{
+ ArrangeOrder, ImageChange, MaskIdentity, 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 b46b35740d..e575b8e065 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -2133,12 +2133,8 @@ fn nested_soft_masks_replay_in_correct_order_and_effective_mask_is_rendered() {
// 3. Rendered-pixel assertion: render output and verify Mask B (left 50x100) is effective, not Mask A (bottom 100x50)
let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default());
- let page = renderer.render(pdfcraft_render::RenderRequest {
- page: 0,
- kind: pdfcraft_render::RequestKind::Pixels,
- scale: 1.0,
- ..Default::default()
- });
+ let page =
+ renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() });
let px = |x: u32, y: u32| {
let idx = ((y * page.width + x) * 4) as usize;
page.rgba[idx..idx + 4].to_vec()
@@ -2242,11 +2238,7 @@ fn non_identity_cm_before_gs_preserves_mask_coordinates_and_rendering() {
let reopened_doc = reopen(&doc);
let reopened_images = images::page_images(&reopened_doc, 0).unwrap();
assert_eq!(reopened_images.len(), 1);
- assert!(
- close(reopened_images[0].rect, [10.0, 10.0, 110.0, 110.0]),
- "Image bounds preserved: {:?}",
- reopened_images[0].rect
- );
+ assert!(close(reopened_images[0].rect, [10.0, 10.0, 110.0, 110.0]), "Image bounds preserved: {:?}", reopened_images[0].rect);
// 3. Render reopened document and assert non-overlapping pixels remain unchanged
let pdf_bytes_after = write_incremental(&reopened_doc, &SaveOptions::default()).unwrap();
@@ -2276,7 +2268,6 @@ fn non_identity_cm_before_gs_preserves_mask_coordinates_and_rendering() {
assert_eq!(px_after(5, 5), px_before(5, 5));
}
-
#[test]
fn form_artwork_edits_only_the_selected_placement() {
let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents
From dd5b7e7735e5ab6448f9747e8fb3ace2a9d9b095 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Thu, 15 Oct 2026 01:54:05 +0530
Subject: [PATCH 017/289] chore: format remaining workspace files
---
crates/engine/src/text_fonts.rs | 1 -
crates/ui-egui/src/canvas.rs | 17 +-
crates/ui-egui/src/content_ui.rs | 58 ++----
crates/ui-egui/src/edit_text_ui.rs | 323 ++++++++++++++++++++---------
crates/ui-egui/src/files.rs | 5 +-
crates/ui-egui/src/panels.rs | 28 ++-
crates/ui-egui/tests/editing.rs | 28 +--
7 files changed, 275 insertions(+), 185 deletions(-)
diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs
index 7c3fc3211e..ca77b0d8af 100644
--- a/crates/engine/src/text_fonts.rs
+++ b/crates/engine/src/text_fonts.rs
@@ -88,4 +88,3 @@ mod tests {
assert!(!dirs.is_empty());
}
}
-
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index aacc091bff..7623913f44 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -3242,7 +3242,9 @@ 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() {
+ 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, can_paste_image, &mut image_action);
return;
}
@@ -3612,7 +3614,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
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) {
+ 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
@@ -3644,13 +3648,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
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,
- });
+ 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);
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 70c03d0a1a..c9d0206413 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -552,24 +552,18 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option {
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"));
- 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());
+ 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());
+ }
+ });
+ ui.horizontal(|ui| {
+ 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}"));
}
});
- ui.horizontal(|ui| {
- 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);
@@ -759,11 +753,7 @@ impl crate::PdfCraftApp {
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.pending_edit = Some(pdfcraft_engine::Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete });
view.image_selection = None;
}
}
@@ -783,7 +773,9 @@ impl crate::PdfCraftApp {
/// 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; };
+ let Some((i, id)) = self.active_ids() else {
+ return false;
+ };
if let Some(doc) = self.session.get(id)
&& !doc.allows_modification()
{
@@ -808,9 +800,10 @@ impl crate::PdfCraftApp {
let image_data: Option<(String, Vec)> = if has_system_cb {
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 == width && *h as usize == height && rgba.as_slice() == bytes.as_slice()
- });
+ let matches_internal = self
+ .last_copied_image
+ .as_ref()
+ .is_some_and(|(w, h, rgba)| *w as usize == width && *h as usize == height && rgba.as_slice() == bytes.as_slice());
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) {
@@ -857,21 +850,10 @@ impl crate::PdfCraftApp {
};
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 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,
- });
+ 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");
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index 7af7b1091e..b2563fb24e 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -244,62 +244,206 @@ pub fn toggle_numbered_list(text: &mut String) -> bool {
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' => 'ᵂ',
+ '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',
+ '⁰' => '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' => 'ₓ',
+ '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',
+ '₀' => '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,
}
}
@@ -317,22 +461,15 @@ fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) {
}
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 (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()
- };
+ 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);
@@ -346,22 +483,14 @@ pub fn toggle_superscript(ed: &mut LineEditor) -> bool {
}
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 (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()
- };
+ 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);
@@ -842,10 +971,18 @@ pub(crate) fn image_input(
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);
+ 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_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);
@@ -853,10 +990,14 @@ pub(crate) fn image_input(
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)));
+ 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::TopLeft,
@@ -875,7 +1016,13 @@ pub(crate) fn image_input(
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);
+ 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 {
@@ -897,11 +1044,7 @@ pub(crate) fn image_input(
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,
- });
+ 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;
@@ -931,7 +1074,10 @@ pub(crate) fn image_input(
}
let origin = ui.input(|i| i.pointer.press_origin());
- if allow_interaction && resp.drag_started() && let Some(o) = 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 = [
@@ -1167,14 +1313,13 @@ pub(crate) fn image_input(
|| 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 {
+ 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.pending_edit =
+ Some(Edit::EditPageImage { page, index: s.index, change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, crop)) });
}
}
view.image_selection = None;
@@ -1278,44 +1423,28 @@ pub(crate) fn image_context_menu(
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),
- });
+ 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),
- });
+ 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),
- });
+ 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),
- });
+ 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);
}
diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs
index a5dbbabd6a..9ffb567fd8 100644
--- a/crates/ui-egui/src/files.rs
+++ b/crates/ui-egui/src/files.rs
@@ -466,9 +466,8 @@ impl PdfCraftApp {
if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes.clone()) })
&& let Some((index, id)) = self.active_ids()
{
- 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)
- });
+ 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()
{
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 6f9758ac9a..7cd417d863 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -359,10 +359,9 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
};
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| {
+ egui::ComboBox::from_id_salt("text-font-dropdown").selected_text(current_font_label).width(ui.available_width().max(180.0)).show_ui(
+ ui,
+ |ui| {
if ui.selectable_label(ed.font_resource.is_none(), tl!("Keep source fonts")).clicked() {
ed.font_resource = None;
changed = true;
@@ -397,9 +396,8 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
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)
- });
+ 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()
&& let Ok(bytes) = std::fs::read(&font.path)
{
@@ -424,18 +422,16 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
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();
- }
- });
+ 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);
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index 9055414169..05357891ea 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -2639,15 +2639,8 @@ fn default_page_context_menu_when_not_in_edit_mode() {
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…",
- ] {
+ 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");
@@ -2666,7 +2659,9 @@ fn default_page_context_menu_when_not_in_edit_mode() {
#[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();
+ 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();
@@ -2738,13 +2733,7 @@ fn tiny_image_crop_handle_drag_does_not_panic() {
// 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,
- });
+ 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
@@ -4420,9 +4409,7 @@ fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() {
#[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();
+ 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();
@@ -4502,4 +4489,3 @@ fn stale_clipboard_plain_text_does_not_paste_image() {
// paste_image should fail and return false without pasting
assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image");
}
-
From 6f9ec0f5764f524a8449ba8dc46fb0c1164f8539 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Thu, 15 Oct 2026 17:27:45 +0530
Subject: [PATCH 018/289] feat(ui): pinned tools section on Home
---
crates/ui-egui/src/dialogs.rs | 90 +++++++++++++++
crates/ui-egui/src/home.rs | 139 +++++++++++++++++------
crates/ui-egui/src/lib.rs | 60 ++++++++++
crates/ui-egui/src/panels.rs | 51 +++++++--
crates/ui-egui/tests/pinned_tools.rs | 163 +++++++++++++++++++++++++++
5 files changed, 457 insertions(+), 46 deletions(-)
create mode 100644 crates/ui-egui/tests/pinned_tools.rs
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 6eceff17e1..0deaf2dd90 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -79,6 +79,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::About => 780.0,
+ Dialog::PinTool => 540.0,
_ => 520.0,
});
match dialog {
@@ -1123,6 +1124,91 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
}
}
}
+ Dialog::PinTool => {
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0)));
+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+ if widgets::ghost_button(ui, "x", tl!("Close")).clicked() {
+ close = true;
+ }
+ });
+ });
+ ui.add_space(4.0);
+ ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted));
+ ui.add_space(10.0);
+
+ ui.horizontal(|ui| {
+ let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
+ crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted);
+ ui.add(
+ egui::TextEdit::singleline(&mut app.pin_tool_search)
+ .hint_text(tl!("Search tools…"))
+ .desired_width(ui.available_width() - 32.0),
+ );
+ if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() {
+ app.pin_tool_search.clear();
+ }
+ });
+ ui.add_space(8.0);
+ ui.separator();
+ ui.add_space(6.0);
+
+ let query = app.pin_tool_search.trim().to_lowercase();
+ egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
+ let mut toggle = None;
+ for g in pdfcraft_engine::catalog::TOOL_GROUPS {
+ let label = tl!(g.label);
+ let blurb = g
+ .sections
+ .first()
+ .map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::>().join(" · "))
+ .unwrap_or_default();
+ if !query.is_empty()
+ && !label.to_lowercase().contains(&query)
+ && !blurb.to_lowercase().contains(&query)
+ && !g.id.to_lowercase().contains(&query)
+ {
+ continue;
+ }
+
+ let pinned = app.is_tool_pinned(g.id);
+ let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click());
+ let press = theme::Press::track(ui, &resp);
+ press.wash(ui, rect, 6, false);
+ let body = rect.translate(press.offset());
+
+ let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
+ crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color);
+
+ let btn_w = 78.0;
+ let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
+ let btn_resp = ui.interact(btn_rect, ui.id().with(("pin_modal_btn", g.id)), egui::Sense::click());
+ let btn_hovered = btn_resp.hovered();
+
+ let (btn_fill, btn_stroke, text_color, btn_label) = if pinned {
+ (t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned"))
+ } else if btn_hovered {
+ (t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin"))
+ } else {
+ (egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin"))
+ };
+
+ ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside);
+ ui.painter().text(btn_rect.center(), egui::Align2::CENTER_CENTER, btn_label, theme::medium(12.0), text_color);
+
+ let max_w = (btn_rect.left() - body.left() - 44.0).max(10.0);
+ widgets::row_text(ui, body.min + egui::vec2(38.0, 6.0), label, theme::semibold(13.5), t.text, max_w);
+ widgets::row_text(ui, body.min + egui::vec2(38.0, 24.0), &blurb, theme::regular(11.0), t.text_muted, max_w);
+
+ if btn_resp.clicked() || resp.clicked() {
+ toggle = Some(g.id);
+ }
+ }
+ if let Some(id) = toggle {
+ app.toggle_pin_tool(id);
+ }
+ });
+ }
}
ui.add_space(16.0);
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
@@ -1136,6 +1222,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
recover = Some(false);
close = true;
}
+ } else if dialog == Dialog::PinTool {
+ if widgets::pill_button(ui, tl!("Done"), true).clicked() {
+ close = true;
+ }
} else if dialog == Dialog::NumberPages {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
apply_number = true;
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 3e70f34e42..5fdcd51ff3 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -18,6 +18,74 @@ fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) {
}
}
+/// Render a single tool card with the standard lift, styling, icon, title, description, and action.
+/// If `allow_unpin` is true, an unpin button appears on hover in the top right corner.
+fn tool_card(
+ app: &mut PdfCraftApp,
+ ui: &mut egui::Ui,
+ t: &Tokens,
+ g: &'static catalog::ToolGroup,
+ allow_unpin: bool,
+) -> Option<&'static str> {
+ let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
+ let press = theme::Press::track(ui, &resp);
+ card_lift(ui, t, rect, press);
+ let fill = press.fill(t.card, t.hover, t.pressed);
+ ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
+ let body = rect.translate(press.offset());
+ let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
+ icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
+
+ let mut unpin_clicked = false;
+ if allow_unpin && (ui.rect_contains_pointer(rect) || resp.hovered()) {
+ let unpin_rect = Rect::from_center_size(body.right_top() + vec2(-18.0, 18.0), vec2(22.0, 22.0));
+ let unpin_resp = ui.interact(unpin_rect, ui.id().with(("unpin_card_btn", g.id)), Sense::click());
+ unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Unpin tool")));
+ if unpin_resp.hovered() {
+ ui.painter().rect_filled(unpin_rect, CornerRadius::same(4), t.pressed);
+ }
+ icons::paint(ui, unpin_rect.shrink(4.0), "x", 14.0, t.icon);
+ if unpin_resp.on_hover_text(tl!("Unpin from Home")).clicked() {
+ unpin_clicked = true;
+ }
+ }
+
+ let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
+ title_job.wrap =
+ egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
+ let title = ui.fonts_mut(|f| f.layout_job(title_job));
+ let title_elided = title.elided;
+ ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
+ let blurb = g
+ .sections
+ .first()
+ .map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · "))
+ .unwrap_or_default();
+ // Fixed-height cards must leave room for their action in every language.
+ let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
+ job.wrap.max_rows = 2;
+ let galley = ui.fonts_mut(|f| f.layout_job(job));
+ let blurb_elided = galley.elided;
+ ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
+ ui.painter().text(
+ body.left_bottom() + vec2(14.0, -14.0),
+ Align2::LEFT_CENTER,
+ tl!("Use now"),
+ theme::medium(12.0),
+ t.accent_text,
+ );
+ let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
+ if unpin_clicked {
+ Some(g.id)
+ } else {
+ if theme::hand(resp).clicked() {
+ app.select_tool(g.id);
+ }
+ None
+ }
+}
+
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
@@ -63,43 +131,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
for id in RECOMMENDED {
let Some(g) = catalog::group(id) else { continue };
- let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
- let press = theme::Press::track(ui, &resp);
- card_lift(ui, &t, rect, press);
- let fill = press.fill(t.card, t.hover, t.pressed);
- ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
- let body = rect.translate(press.offset());
- let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
- icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
- let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
- title_job.wrap =
- egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
- let title = ui.fonts_mut(|f| f.layout_job(title_job));
- let title_elided = title.elided;
- ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
- let blurb = g
- .sections
- .first()
- .map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · "))
- .unwrap_or_default();
- // Fixed-height cards must leave room for their action in every language.
- let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
- job.wrap.max_rows = 2;
- let galley = ui.fonts_mut(|f| f.layout_job(job));
- let blurb_elided = galley.elided;
- ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
- ui.painter().text(
- body.left_bottom() + vec2(14.0, -14.0),
- Align2::LEFT_CENTER,
- tl!("Use now"),
- theme::medium(12.0),
- t.accent_text,
- );
- let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
- if theme::hand(resp).clicked() {
- app.select_tool(g.id);
- }
+ tool_card(app, ui, &t, g, false);
}
let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open file")));
@@ -121,6 +153,39 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
});
+ ui.add_space(26.0);
+ let mut unpin_tool = None;
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(tl!("Pinned Tools")).font(theme::semibold(17.0)));
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if widgets::ghost_button(ui, "pin", tl!("Pin a tool…")).clicked() {
+ app.dialog = Some(crate::Dialog::PinTool);
+ }
+ });
+ });
+ ui.add_space(4.0);
+ if app.pinned_tools.is_empty() {
+ ui.label(
+ egui::RichText::new(tl!("Pin your favorite tools here for quick access."))
+ .color(t.text_muted),
+ );
+ } else {
+ ui.add_space(6.0);
+ let pinned = app.pinned_tools.clone();
+ ui.horizontal_wrapped(|ui| {
+ ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
+ for id in &pinned {
+ let Some(g) = catalog::group(id) else { continue };
+ if let Some(to_unpin) = tool_card(app, ui, &t, g, true) {
+ unpin_tool = Some(to_unpin);
+ }
+ }
+ });
+ }
+ if let Some(id) = unpin_tool {
+ app.unpin_tool(id);
+ }
+
#[cfg(not(target_arch = "wasm32"))]
{
ui.add_space(26.0);
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 49fb3b5fcb..2535adae86 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -338,6 +338,8 @@ pub enum Dialog {
AuditSpace,
/// Signatures ▸ Show certificate.
CertificateViewer,
+ /// Pin tools to Home: selection modal.
+ PinTool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -574,6 +576,10 @@ pub struct PdfCraftApp {
open_prepare_gate: Option>,
/// Folders pinned to Home, and what they held when last listed.
pub pinned: folders_ui::PinnedFolders,
+ /// Tools pinned to Home, in user-defined order.
+ pub pinned_tools: Vec,
+ /// Search query for the Pin a tool selection dialog.
+ pub pin_tool_search: String,
pub toast: Option<(String, f64)>,
/// Whether the macOS title bar is drawn by us (traffic lights over our tab strip).
pub integrated_titlebar: bool,
@@ -922,6 +928,8 @@ impl PdfCraftApp {
#[cfg(all(test, not(target_arch = "wasm32")))]
open_prepare_gate: None,
pinned: Default::default(),
+ pinned_tools: Vec::new(),
+ pin_tool_search: String::new(),
toast: None,
integrated_titlebar: false,
window_controls: None,
@@ -2026,6 +2034,48 @@ impl PdfCraftApp {
self.pending_panel_reveals.push(docking::Panel::Tools);
}
+ /// Check whether a tool group is pinned to Home.
+ pub fn is_tool_pinned(&self, id: &str) -> bool {
+ self.pinned_tools.iter().any(|t| t == id)
+ }
+
+ /// Pin a tool group to Home. Returns true if newly pinned.
+ pub fn pin_tool(&mut self, id: &str) -> bool {
+ if pdfcraft_engine::catalog::group(id).is_none() {
+ return false;
+ }
+ if !self.is_tool_pinned(id) {
+ self.pinned_tools.push(id.to_string());
+ if let Some(g) = pdfcraft_engine::catalog::group(id) {
+ self.notify_fmt("Pinned {tool} to Home", &[("tool", tl!(g.label))]);
+ }
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Remove a tool group from Home.
+ pub fn unpin_tool(&mut self, id: &str) {
+ if let Some(pos) = self.pinned_tools.iter().position(|t| t == id) {
+ self.pinned_tools.remove(pos);
+ if let Some(g) = pdfcraft_engine::catalog::group(id) {
+ self.notify_fmt("Unpinned {tool} from Home", &[("tool", tl!(g.label))]);
+ }
+ }
+ }
+
+ /// Toggle a tool group's pinned state on Home.
+ pub fn toggle_pin_tool(&mut self, id: &str) -> bool {
+ if self.is_tool_pinned(id) {
+ self.unpin_tool(id);
+ false
+ } else {
+ self.pin_tool(id);
+ true
+ }
+ }
+
/// Enter a tool from the catalogue, Home, palette or control channel. Organize is a
/// workspace, so opening its panel also opens the page grid (without toggling it off).
pub(crate) fn select_tool(&mut self, id: &'static str) {
@@ -2111,6 +2161,7 @@ impl PdfCraftApp {
"reload_changed_files": self.reload_changed_files,
"system_title_bar": self.system_title_bar,
"pinned_folders": self.pinned.folders,
+ "pinned_tools": self.pinned_tools,
"theme": self.theme_preference,
"scroll_speed": self.scroll_speed,
"animation_effects": self.animation_effects,
@@ -2200,6 +2251,15 @@ impl PdfCraftApp {
self.reload_changed_files = on;
}
self.pinned.restore(&v["pinned_folders"]);
+ if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) {
+ let mut tools = Vec::new();
+ for item in items.iter().filter_map(serde_json::Value::as_str) {
+ if pdfcraft_engine::catalog::group(item).is_some() && !tools.contains(&item.to_string()) {
+ tools.push(item.to_string());
+ }
+ }
+ self.pinned_tools = tools;
+ }
if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) {
self.set_theme_preference(preference);
}
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 7cd417d863..71e6e14e07 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -58,7 +58,11 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let shown = if app.all_tools_expanded { TOOL_GROUPS.len() } else { COLLAPSED_TOOLS.min(TOOL_GROUPS.len()) };
for g in &TOOL_GROUPS[..shown] {
- if tool_row(ui, t, g).clicked() {
+ let pinned = app.is_tool_pinned(g.id);
+ let (resp, pin_clicked) = tool_row(ui, t, g, pinned);
+ if pin_clicked {
+ app.toggle_pin_tool(g.id);
+ } else if resp.clicked() {
app.select_tool(g.id);
}
}
@@ -89,21 +93,29 @@ fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32,
elided
}
-fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
+fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, is_pinned: bool) -> (egui::Response, bool) {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
let press = theme::Press::track(ui, &resp);
press.wash(ui, rect, 6, false);
let body = rect.translate(press.offset());
icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 7.0), vec2(20.0, 20.0)), g.icon, 19.0, hue(g));
- let right_reserved = match (g.badge, g.availability) {
- (Some(b), _) => ui.fonts_mut(|f| f.layout_no_wrap(tl!(b).to_owned(), theme::semibold(9.5), Color32::WHITE).size().x) + 26.0,
+
+ let pin_rect = Rect::from_center_size(body.right_center() - vec2(14.0, 0.0), vec2(22.0, 22.0));
+ let pin_resp = ui.interact(pin_rect, ui.id().with(("pin_toggle_row", g.id)), Sense::click());
+ let pin_hovered = pin_resp.hovered();
+ let mut pin_clicked = false;
+
+ let pin_space = 26.0;
+ let badge_or_hint_reserved = match (g.badge, g.availability) {
+ (Some(b), _) => ui.fonts_mut(|f| f.layout_no_wrap(tl!(b).to_owned(), theme::semibold(9.5), Color32::WHITE).size().x) + 18.0,
(None, Availability::Planned(m)) if resp.hovered() => {
let hint = format!("{} · {m}", tl!("Planned"));
- ui.fonts_mut(|f| f.layout_no_wrap(hint, theme::medium(10.5), t.text_faint).size().x) + 18.0
+ ui.fonts_mut(|f| f.layout_no_wrap(hint, theme::medium(10.5), t.text_faint).size().x) + 10.0
}
- _ => 8.0,
+ _ => 0.0,
};
+ let right_reserved = pin_space + badge_or_hint_reserved + 8.0;
let clipped =
row_label(ui, tl!(g.label), theme::regular(13.5), t.text, body.left_center() + vec2(36.0, 0.0), rect.width() - 36.0 - right_reserved);
match (g.badge, g.availability) {
@@ -111,14 +123,14 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
let font = theme::semibold(9.5);
let badge = tl!(b);
let w = ui.fonts_mut(|f| f.layout_no_wrap(badge.to_string(), font.clone(), Color32::WHITE).size().x);
- let r = Rect::from_center_size(body.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0));
+ let r = Rect::from_center_size(body.right_center() - vec2(pin_space + w / 2.0 + 4.0, 0.0), vec2(w + 10.0, 16.0));
ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new);
ui.painter().text(r.center(), Align2::CENTER_CENTER, badge, font, Color32::WHITE);
}
// Planned tools: a quiet milestone hint instead of a chip, so the list stays calm.
(None, Availability::Planned(m)) if resp.hovered() => {
ui.painter().text(
- body.right_center() - vec2(10.0, 0.0),
+ body.right_center() - vec2(pin_space + 4.0, 0.0),
Align2::RIGHT_CENTER,
format!("{} · {m}", tl!("Planned")),
theme::medium(10.5),
@@ -127,13 +139,34 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
}
_ => {}
}
+
+ let row_hovered = ui.rect_contains_pointer(rect) || resp.hovered();
+ if is_pinned || row_hovered || pin_hovered {
+ if pin_hovered {
+ ui.painter().rect_filled(pin_rect, CornerRadius::same(4), t.pressed);
+ }
+ let pin_tint = if is_pinned {
+ t.accent_text
+ } else if pin_hovered {
+ t.text
+ } else {
+ t.text_faint
+ };
+ icons::paint(ui, pin_rect.shrink(3.0), "pin", 15.0, pin_tint);
+ let tip = if is_pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
+ pin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
+ if theme::hand(pin_resp).on_hover_text(tip).clicked() {
+ pin_clicked = true;
+ }
+ }
+
let tip = match g.availability {
Availability::Ready => tl!("Available").to_string(),
Availability::Planned(m) => format!("{} {m} — {}", tl!("Planned for milestone"), tl!("open to see what it will include")),
Availability::Provider => tl!("Optional: needs an AI provider you configure").into(),
};
let tip = if clipped { format!("{} — {tip}", tl!(g.label)) } else { tip };
- theme::hand(resp).on_hover_text(tip)
+ (theme::hand(resp).on_hover_text(tip), pin_clicked)
}
fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) {
diff --git a/crates/ui-egui/tests/pinned_tools.rs b/crates/ui-egui/tests/pinned_tools.rs
new file mode 100644
index 0000000000..55c84bc528
--- /dev/null
+++ b/crates/ui-egui/tests/pinned_tools.rs
@@ -0,0 +1,163 @@
+//! Pinned tools on Home: pin tools, unpin tools, persistence across sessions,
+//! selection modal, and the sidebar pin toggle.
+
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use pdfcraft_ui_egui::{Dialog, LeftPanel, PdfCraftApp};
+
+fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).build_eframe(move |_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app.run_inline = true;
+ setup(&mut app);
+ app
+ });
+ h.run_steps(4);
+ h
+}
+
+#[test]
+fn state_operations_and_validation() {
+ let mut app = PdfCraftApp::new();
+ assert!(!app.is_tool_pinned("redact"));
+
+ // Pinning a valid tool
+ assert!(app.pin_tool("redact"));
+ assert!(app.is_tool_pinned("redact"));
+ assert_eq!(app.pinned_tools, vec!["redact"]);
+
+ // Pinning already pinned tool returns false (no duplicates)
+ assert!(!app.pin_tool("redact"));
+ assert_eq!(app.pinned_tools, vec!["redact"]);
+
+ // Pinning an invalid tool returns false
+ assert!(!app.pin_tool("invalid_tool_unknown_id"));
+ assert_eq!(app.pinned_tools, vec!["redact"]);
+
+ // Pinning a second valid tool
+ assert!(app.pin_tool("form"));
+ assert_eq!(app.pinned_tools, vec!["redact", "form"]);
+
+ // Unpinning
+ app.unpin_tool("redact");
+ assert!(!app.is_tool_pinned("redact"));
+ assert!(app.is_tool_pinned("form"));
+ assert_eq!(app.pinned_tools, vec!["form"]);
+
+ // Unpinning non-existent tool does nothing
+ app.unpin_tool("nonexistent");
+ assert_eq!(app.pinned_tools, vec!["form"]);
+
+ // Toggling
+ assert!(!app.toggle_pin_tool("form")); // unpins
+ assert!(!app.is_tool_pinned("form"));
+ assert!(app.pinned_tools.is_empty());
+
+ assert!(app.toggle_pin_tool("form")); // pins
+ assert!(app.is_tool_pinned("form"));
+ assert_eq!(app.pinned_tools, vec!["form"]);
+}
+
+#[test]
+fn persistence_round_trip_and_sanitization() {
+ let mut app = PdfCraftApp::new();
+ app.pin_tool("protect");
+ app.pin_tool("redact");
+
+ let saved = app.persist();
+ assert!(saved.contains("\"pinned_tools\""));
+
+ let mut restored = PdfCraftApp::new();
+ restored.restore(&saved);
+ assert_eq!(restored.pinned_tools, vec!["protect", "redact"]);
+
+ // Test sanitization: unknown tools are discarded and duplicates are removed, preserving order
+ let malicious_or_corrupt = serde_json::json!({
+ "pinned_tools": ["redact", "nonexistent_junk", "redact", "edit", "another_unknown", "protect"]
+ })
+ .to_string();
+ restored.restore(&malicious_or_corrupt);
+ assert_eq!(restored.pinned_tools, vec!["redact", "edit", "protect"]);
+}
+
+#[test]
+fn home_shows_pinned_tools_section_and_placeholder() {
+ let mut h = harness(|_| {});
+ // When no tools are pinned, the placeholder message appears
+ h.get_by_label_contains("Pinned Tools");
+ h.get_by_label_contains("Pin your favorite tools here for quick access.");
+ h.get_by_label("Pin a tool…");
+
+ // Pin a tool and step forward
+ h.state_mut().pin_tool("redact");
+ h.run_steps(3);
+
+ // Placeholder is gone and Redact card is shown
+ assert!(h.query_by_label_contains("Pin your favorite tools here for quick access.").is_none());
+ assert!(h.query_all_by_label("Redact a PDF").count() >= 1);
+
+ // Unpin tool and verify placeholder returns
+ h.state_mut().unpin_tool("redact");
+ h.run_steps(3);
+ h.get_by_label_contains("Pin your favorite tools here for quick access.");
+}
+
+#[test]
+fn clicking_pinned_tool_card_selects_it() {
+ let mut h = harness(|app| {
+ app.pin_tool("redact");
+ });
+ h.run_steps(2);
+
+ // The Redact card is rendered and can be clicked to select the tool
+ let nodes: Vec<_> = h.query_all_by_label("Redact a PDF").collect();
+ assert!(!nodes.is_empty());
+ // Click the card
+ nodes.last().unwrap().click();
+ h.run_steps(3);
+
+ // Redact tool panel is now active
+ assert_eq!(h.state().left, LeftPanel::Tool("redact"));
+}
+
+#[test]
+fn pin_tool_dialog_workflow() {
+ let mut h = harness(|app| {
+ app.dialog = Some(Dialog::PinTool);
+ });
+ h.run_steps(3);
+
+ // Modal is open
+ h.get_by_label_contains("Pin tools to Home");
+ h.get_by_label("Done");
+
+ // Pin a tool from inside the dialog
+ assert!(!h.state().is_tool_pinned("scan"));
+ h.state_mut().toggle_pin_tool("scan");
+ assert!(h.state().is_tool_pinned("scan"));
+ h.run_steps(2);
+
+ // Close dialog with "Done"
+ h.get_by_label("Done").click();
+ h.run_steps(2);
+ assert!(h.state().dialog.is_none());
+}
+
+#[test]
+fn sidebar_pin_toggle_unpins_and_pins() {
+ let mut h = harness(|app| {
+ app.pin_tool("redact");
+ });
+ h.run_steps(2);
+ assert!(h.state().is_tool_pinned("redact"));
+
+ // Since redact is pinned, the pin toggle is rendered in the sidebar with "Unpin from Home"
+ let unpin_btn = h.get_by_label("Unpin from Home");
+ unpin_btn.click();
+ h.run_steps(3);
+
+ assert!(!h.state().is_tool_pinned("redact"));
+ // Left panel did not navigate away (still AllTools)
+ assert_eq!(h.state().left, LeftPanel::AllTools);
+}
From 035183777a113c17b9e64282796a029df2d10e18 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Fri, 16 Oct 2026 21:24:49 +0530
Subject: [PATCH 019/289] fix:implement 8 point non destructive crop, image
resize and Arrange extraction bugs
---
ATTRIBUTION.toml | 10 +-
crates/edit/src/images.rs | 225 +-
crates/edit/src/tests.rs | 169 +
crates/engine/src/copy_image.rs | 9 +-
crates/engine/src/lib.rs | 4 +
crates/engine/src/tests.rs | 7 +
crates/ui-egui/src/canvas.rs | 554 +-
crates/ui-egui/src/content_ui.rs | 141 +-
crates/ui-egui/src/dialogs.rs | 27 +-
crates/ui-egui/src/edit_text_ui.rs | 893 +-
crates/ui-egui/src/home.rs | 38 +-
crates/ui-egui/src/i18n/bg.tsv | 2 +-
crates/ui-egui/src/i18n/es.tsv | 6 +
crates/ui-egui/src/i18n/lt.tsv | 8 +
crates/ui-egui/src/i18n/mod.rs | 1 +
crates/ui-egui/src/i18n/vi.tsv | 71 +-
crates/ui-egui/src/i18n/zh-hant.tsv | 2 -
crates/ui-egui/src/image_engine.rs | 645 +
crates/ui-egui/src/image_pick.rs | 15 +-
crates/ui-egui/src/lib.rs | 19 +-
crates/ui-egui/src/panels.rs | 26 +-
crates/ui-egui/tests/editing.rs | 433 +
crates/ui-egui/tests/print.rs | 9 +-
parity/acrobat-features.toml | 20306 +++++++++++++-------------
24 files changed, 13092 insertions(+), 10528 deletions(-)
create mode 100644 crates/ui-egui/src/image_engine.rs
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 3f3791f719..b51fb682c9 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -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 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2"
+sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.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 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679"
+sha256 = "59d3464945d5869dc9b6a6163adf6c6a1a8d1a1da2e5d69c107db9d8c264175d"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.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 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e"
+sha256 = "129afc635aa7249073901ed4f661dd0b84c3efee2ee09a351066be7d45c9c2f6"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.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 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5"
+sha256 = "da3e4e8d5fad4d744360f2f9c35d9f94e6fbd02b3f8076cb93a17af56d234cdf"
[[asset]]
@@ -240,7 +240,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3"
+sha256 = "50c16fa90a4d36de4fb7a3ec9b0c5dfd0cdb896515a5c7f59e74681074e38db4"
[[asset]]
path = "assets/icons/accessibility.svg"
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index cd4dbade44..46d4cee891 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -592,9 +592,12 @@ fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec,
- state_ops: Vec,
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct ExtractedScopeState {
+ pub clip_ops: Vec,
+ pub state_ops: Vec,
+ pub effective_cm: Matrix,
+ pub do_op: Op,
}
#[derive(Clone, Debug, Default, PartialEq)]
@@ -711,12 +714,85 @@ impl ExtendedGStateTracker {
}
}
-fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Matrix) -> Result, EditError> {
+fn clip_path_to_ops(clip: &ClipPath) -> Vec {
+ let mut ops = Vec::new();
+ let mut current_pt = Point::ZERO;
+ for el in clip.path.elements() {
+ match el {
+ kurbo::PathEl::MoveTo(p) => {
+ if !p.x.is_finite() || !p.y.is_finite() {
+ return Vec::new();
+ }
+ current_pt = *p;
+ ops.push(Op::new("m", vec![pdfcraft_content::num(p.x), pdfcraft_content::num(p.y)]));
+ }
+ kurbo::PathEl::LineTo(p) => {
+ if !p.x.is_finite() || !p.y.is_finite() {
+ return Vec::new();
+ }
+ current_pt = *p;
+ ops.push(Op::new("l", vec![pdfcraft_content::num(p.x), pdfcraft_content::num(p.y)]));
+ }
+ kurbo::PathEl::QuadTo(p1, p2) => {
+ if !p1.x.is_finite() || !p1.y.is_finite() || !p2.x.is_finite() || !p2.y.is_finite() {
+ return Vec::new();
+ }
+ let c1_x = current_pt.x + (p1.x - current_pt.x) * (2.0 / 3.0);
+ let c1_y = current_pt.y + (p1.y - current_pt.y) * (2.0 / 3.0);
+ let c2_x = p2.x + (p1.x - p2.x) * (2.0 / 3.0);
+ let c2_y = p2.y + (p1.y - p2.y) * (2.0 / 3.0);
+ if !c1_x.is_finite() || !c1_y.is_finite() || !c2_x.is_finite() || !c2_y.is_finite() {
+ return Vec::new();
+ }
+ current_pt = *p2;
+ ops.push(Op::new(
+ "c",
+ vec![
+ pdfcraft_content::num(c1_x),
+ pdfcraft_content::num(c1_y),
+ pdfcraft_content::num(c2_x),
+ pdfcraft_content::num(c2_y),
+ pdfcraft_content::num(p2.x),
+ pdfcraft_content::num(p2.y),
+ ],
+ ));
+ }
+ kurbo::PathEl::CurveTo(p1, p2, p3) => {
+ if !p1.x.is_finite() || !p1.y.is_finite() || !p2.x.is_finite() || !p2.y.is_finite() || !p3.x.is_finite() || !p3.y.is_finite() {
+ return Vec::new();
+ }
+ current_pt = *p3;
+ ops.push(Op::new(
+ "c",
+ vec![
+ pdfcraft_content::num(p1.x),
+ pdfcraft_content::num(p1.y),
+ pdfcraft_content::num(p2.x),
+ pdfcraft_content::num(p2.y),
+ pdfcraft_content::num(p3.x),
+ pdfcraft_content::num(p3.y),
+ ],
+ ));
+ }
+ kurbo::PathEl::ClosePath => {
+ ops.push(Op::new("h", vec![]));
+ }
+ }
+ }
+ if !ops.is_empty() {
+ ops.push(Op::new(if clip.even_odd { "W*" } else { "W" }, vec![]));
+ ops.push(Op::new("n", vec![]));
+ }
+ ops
+}
+
+pub(crate) fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Matrix) -> Result, EditError> {
if target >= ops.len() || start > target {
return Ok(None);
}
+ let target_op = ops.get(target).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
if start == target {
- return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new() }));
+ return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new(), effective_cm: initial_ctm, do_op: target_op }));
}
let mut clip_ops = Vec::new();
let mut state_ops = Vec::new();
@@ -807,7 +883,7 @@ fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Mat
return Ok(None);
}
- Ok(Some(ExtractedScopeState { clip_ops, state_ops }))
+ Ok(Some(ExtractedScopeState { clip_ops, state_ops, effective_cm: matrix, do_op: target_op }))
}
/// Change the stacking order of image `index` on `page`.
@@ -885,7 +961,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
)
});
- let (target_span, scope_start) = if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target)
+ let (mut 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
{
@@ -947,14 +1023,30 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into()));
}
- 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, scope_start_ctm)?;
- let Some(extracted) = extracted else {
- return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()));
+ let mut extracted = match extract_scope_state(&ops, scope_start, target, scope_start_ctm)? {
+ Some(ext) => ext,
+ None if scope_start != target => {
+ let target_op = ops.get(target).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
+ target_span = target_op.span.start..target_op.span.end;
+ extract_scope_state(&ops, target, target, Matrix(img.draw_matrix))?
+ .ok_or_else(|| EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()))?
+ }
+ None => {
+ return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()));
+ }
};
- if is_clipped && extracted.clip_ops.is_empty() {
+ if extracted.clip_ops.is_empty() && !img.clips.is_empty() {
+ for clip in &img.clips {
+ let ops = clip_path_to_ops(clip);
+ if !ops.is_empty() {
+ extracted.clip_ops.extend(ops);
+ }
+ }
+ }
+
+ let has_inherited_clip = !img.clips.is_empty() && extracted.clip_ops.is_empty();
+ if has_inherited_clip {
return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into()));
}
@@ -1025,7 +1117,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
}
}
}
- unit_ops.push(ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])));
+ unit_ops.push(extracted.do_op);
unit_ops.push(Op::new("Q", vec![]));
let mut serialized = Vec::new();
@@ -1156,9 +1248,7 @@ fn extract_image_drawing_ops(ops: &[Op], start: usize, end: usize, scope_matrix:
while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) {
k = k.saturating_add(1);
}
- if k > i
- && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*"))
- && ops.get(k.saturating_add(1)).is_some_and(|o| o.is("n"))
+ if k > i && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k.saturating_add(1)).is_some_and(|o| o.is("n"))
{
i = k.saturating_add(2);
if scope_matrix != Matrix::IDENTITY
@@ -1406,6 +1496,7 @@ fn transform_image(
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform is too large".into()));
}
+
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
@@ -1456,10 +1547,43 @@ fn crop_image_impl(
if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into()));
}
- let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])];
- if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
+ let is_normalized = keep[0] >= 0.0
+ && keep[1] >= 0.0
+ && keep[2] <= 1.0
+ && keep[3] <= 1.0
+ && keep[2] > keep[0]
+ && keep[3] > keep[1]
+ && ((image.rect[2] - image.rect[0]).abs() > 1.5 || (image.rect[3] - image.rect[1]).abs() > 1.5);
+
+ let (u0, v0, u1, v1) = if is_normalized {
+ (keep[0], keep[1], keep[2], keep[3])
+ } else {
+ let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])];
+ if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
+ return Err(EditError::Invalid("the crop box has no area".into()));
+ }
+ let inv = Matrix(image.matrix).invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
+ let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inv.apply(x, y));
+ let min_u = corners.iter().map(|(u, _)| *u).fold(f64::INFINITY, f64::min).clamp(0.0, 1.0);
+ let max_u = corners.iter().map(|(u, _)| *u).fold(f64::NEG_INFINITY, f64::max).clamp(0.0, 1.0);
+ let min_v = corners.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min).clamp(0.0, 1.0);
+ let max_v = corners.iter().map(|(_, v)| *v).fold(f64::NEG_INFINITY, f64::max).clamp(0.0, 1.0);
+ if max_u - min_u < 1e-6 || max_v - min_v < 1e-6 {
+ return Err(EditError::Invalid("the crop box has no area".into()));
+ }
+ (min_u, min_v, max_u, max_v)
+ };
+
+ if u1 - u0 < 1e-6 || v1 - v0 < 1e-6 {
return Err(EditError::Invalid("the crop box has no area".into()));
}
+
+ let m = Matrix(image.matrix);
+ let p00 = m.apply(u0, v0);
+ let p10 = m.apply(u1, v0);
+ let p11 = m.apply(u1, v1);
+ let p01 = m.apply(u0, v1);
+
let mut offset = 0_usize;
for (_, data) in streams.iter().take(image.stream) {
offset = offset
@@ -1478,16 +1602,36 @@ fn crop_image_impl(
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
- let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
+ let corners = [p00, p10, p11, p01].map(|(x, y)| inverse.apply(x, y));
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
return Err(EditError::Invalid("the crop box is too large for the image's placement".into()));
}
- let clip: Vec = corners
- .iter()
- .enumerate()
- .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)]))
- .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])])
- .collect();
+ let is_axis_aligned = ((corners[0].1 - corners[1].1).abs() < 1e-4
+ && (corners[1].0 - corners[2].0).abs() < 1e-4
+ && (corners[2].1 - corners[3].1).abs() < 1e-4
+ && (corners[3].0 - corners[0].0).abs() < 1e-4)
+ || ((corners[0].0 - corners[1].0).abs() < 1e-4
+ && (corners[1].1 - corners[2].1).abs() < 1e-4
+ && (corners[2].0 - corners[3].0).abs() < 1e-4
+ && (corners[3].1 - corners[0].1).abs() < 1e-4);
+ let clip: Vec = if is_axis_aligned {
+ let min_x = corners.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
+ let max_x = corners.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max);
+ let min_y = corners.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
+ let max_y = corners.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max);
+ vec![
+ Op::new("re", vec![Object::Real(min_x), Object::Real(min_y), Object::Real(max_x - min_x), Object::Real(max_y - min_y)]),
+ Op::new("W", vec![]),
+ Op::new("n", vec![]),
+ ]
+ } else {
+ corners
+ .iter()
+ .enumerate()
+ .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)]))
+ .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])])
+ .collect()
+ };
// The image's own earlier crop (a clipping path right after the scope's `q`) gives way to
// the new one, so a crop can grow again as well as shrink.
let old_clip: Option> = (start != end)
@@ -1606,3 +1750,32 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+
+/// Post-multiplies the image's existing CTM `[a, b, c, d, e, f]` by the crop transform
+/// `[u_min, v_min, u_max, v_max]` to shrink the footprint without stretching.
+pub fn crop_ctm(ctm: [f64; 6], crop: [f64; 4]) -> [f64; 6] {
+ let [a, b, c, d, e, f] = ctm;
+ let [u_min, v_min, u_max, v_max] = crop;
+ let new_a = a * (u_max - u_min);
+ let new_b = b * (u_max - u_min);
+ let new_c = c * (v_max - v_min);
+ let new_d = d * (v_max - v_min);
+ let new_e = e + a * u_min + c * v_min;
+ let new_f = f + b * u_min + d * v_min;
+ [new_a, new_b, new_c, new_d, new_e, new_f]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_crop_ctm_standard_non_stretched() {
+ // Standard upright CTM: width=200, height=100 at origin (50, 100)
+ let ctm = [200.0, 0.0, 0.0, 100.0, 50.0, 100.0];
+ let crop = [0.25, 0.25, 0.75, 0.75];
+ let cropped = crop_ctm(ctm, crop);
+ let expected = [100.0, 0.0, 0.0, 50.0, 100.0, 125.0];
+ assert_eq!(cropped, expected);
+ }
+}
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 95968392f6..e224565d4a 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -2058,6 +2058,39 @@ fn image_arrange_stacking_order_and_remove_crop() {
assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect));
}
+#[test]
+fn crop_rotated_image_preserves_angle_and_in_place_geometry() {
+ let content = "q 173.20508 100 -50 86.60254 100 100 cm /Im0 Do Q";
+ let mut doc = image_page_with(content);
+ let img_before = &images::page_images(&doc, 0).unwrap()[0];
+ let m_before = img_before.matrix;
+ assert!((m_before[1] - 100.0).abs() < 1e-3);
+ assert!((m_before[2] - (-50.0)).abs() < 1e-3);
+
+ images::crop_image(&mut doc, 0, 0, [0.25, 0.25, 0.75, 0.75]).unwrap();
+ let doc = reopen(&doc);
+ let img_cropped = &images::page_images(&doc, 0).unwrap()[0];
+
+ let m_after = img_cropped.matrix;
+ assert!((m_after[0] - m_before[0]).abs() < 1e-3);
+ assert!((m_after[1] - m_before[1]).abs() < 1e-3);
+ assert!((m_after[2] - m_before[2]).abs() < 1e-3);
+ assert!((m_after[3] - m_before[3]).abs() < 1e-3);
+ assert!((m_after[4] - m_before[4]).abs() < 1e-3);
+ assert!((m_after[5] - m_before[5]).abs() < 1e-3);
+
+ let bytes = page_content_bytes(&doc, 0);
+ let str_content = String::from_utf8_lossy(&bytes);
+ assert!(str_content.contains("re") && str_content.contains("W\nn"), "{str_content}");
+ assert!(str_content.contains("/Im0 Do"), "{str_content}");
+
+ 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.matrix, m_before);
+}
+
#[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";
@@ -2252,6 +2285,108 @@ fn unwrapped_image_moves_successfully() {
assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: original cm preserved");
}
+#[test]
+fn test_arrange_unwrapped_image_extraction() {
+ let content = b"100 0 0 100 100 100 cm /Im0 Do /Im1 Do";
+ let ops = pdfcraft_content::parse(content).ops;
+ let target = ops.iter().position(|op| op.is("Do") && op.name(0) == Some(b"Im0")).expect("target /Im0 not found");
+
+ // Scope start for unwrapped image matches target because content_units isolates Do
+ let scope_start = target;
+
+ // Accumulate effective CTM prior to the image operation
+ let mut initial_ctm = pdfcraft_content::Matrix::IDENTITY;
+ for op in &ops[..target] {
+ if op.is("cm")
+ && let Some(m) = op.nums::<6>()
+ {
+ initial_ctm = pdfcraft_content::Matrix(m).then(&initial_ctm);
+ }
+ }
+ assert_eq!(initial_ctm, pdfcraft_content::Matrix([100.0, 0.0, 0.0, 100.0, 100.0, 100.0]));
+
+ let extracted = images::extract_scope_state(&ops, scope_start, target, initial_ctm)
+ .expect("extraction must not return Err")
+ .expect("extraction must return Some rather than None");
+
+ assert_eq!(extracted.do_op.op.as_slice(), b"Do");
+ assert_eq!(extracted.do_op.name(0), Some(b"Im0".as_slice()));
+ assert_eq!(extracted.effective_cm, pdfcraft_content::Matrix([100.0, 0.0, 0.0, 100.0, 100.0, 100.0]));
+}
+
+#[test]
+fn arrange_unwrapped_image_in_mixed_scope_succeeds() {
+ // Content unit contains text and an unwrapped image; scope extraction of unit fails on BT,
+ // so it safely falls back to extracting target Do directly.
+ let content = "q BT /F1 12 Tf 72 700 Td (Preceding Text) Tj ET 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 500 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);
+
+ images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
+ let doc = reopen(&doc);
+
+ 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 do_pos = str_content.rfind("/Im0 Do").unwrap();
+ assert!(text_pos < do_pos, "moved image follows External Text");
+
+ 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]);
+}
+
+#[test]
+fn image_arrange_with_transformed_clipping_path_succeeds() {
+ // Nested graphics state: outer scope establishes a scaled clipping path (cm + path + W n),
+ // inner scope draws the image.
+ let content = "q 0.5 0 0 0.5 0 0 cm 100 100 m 300 100 l 300 300 l 100 300 l h W n 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 before = images::page_images(&doc, 0).unwrap();
+ assert_eq!(before.len(), 1);
+ assert!(before[0].visible_rect.is_some());
+ let rect_before = before[0].rect;
+ let clip_before = before[0].visible_rect;
+
+ 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);
+ assert_eq!(after[0].rect, rect_before);
+ assert_eq!(after[0].visible_rect, clip_before);
+
+ 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 do_pos = str_content.rfind("/Im0 Do").unwrap();
+ assert!(text_pos < do_pos, "image moved after text: {str_content}");
+}
+
+#[test]
+fn biomolecules_image_arrange_with_inherited_clips_succeeds() {
+ let Ok(data) = std::fs::read("C:\\Users\\Harsh\\Downloads\\Biomolecules.pdf") else { return };
+ let mut doc = Document::open(std::sync::Arc::new(data)).unwrap();
+
+ // Page 0, image 1
+ images::arrange_image(&mut doc, 0, 1, images::ArrangeOrder::SendBackward).unwrap();
+ images::arrange_image(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap();
+
+ // Page 6, images
+ let imgs_p6 = images::page_images(&doc, 6).unwrap();
+ for i in 0..imgs_p6.len() {
+ images::arrange_image(&mut doc, 6, i, images::ArrangeOrder::BringToFront).unwrap();
+ images::arrange_image(&mut doc, 6, i, images::ArrangeOrder::SendBackward).unwrap();
+ }
+
+ // Page 8, images
+ let imgs_p8 = images::page_images(&doc, 8).unwrap();
+ for i in 0..imgs_p8.len() {
+ images::arrange_image(&mut doc, 8, i, images::ArrangeOrder::BringToFront).unwrap();
+ images::arrange_image(&mut doc, 8, i, images::ArrangeOrder::SendBackward).unwrap();
+ }
+}
+
#[test]
fn inherited_source_opacity_is_preserved() {
// Outer q sets /GS0 gs; inner q draws /Im0 Do; outer Q restores state before External Text
@@ -4845,3 +4980,37 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st
}
}
}
+
+#[test]
+fn test_image_move_does_not_emit_whiteout() {
+ let mut doc = image_page();
+ let imgs = images::page_images(&doc, 0).unwrap();
+ assert_eq!(imgs.len(), 1);
+ let original_rect = imgs[0].rect;
+
+ // Move the image to a new location
+ let target_rect = [original_rect[0] + 50.0, original_rect[1] + 100.0, original_rect[2] + 50.0, original_rect[3] + 100.0];
+ let t = images::rect_to_rect(original_rect, target_rect);
+ images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
+ let doc = reopen(&doc);
+
+ // Inspect the resulting PDF content stream
+ let bytes = page_content_bytes(&doc, 0);
+ let content = String::from_utf8_lossy(&bytes);
+
+ // Assert that no white fill operators were added
+ assert!(!content.contains("1 1 1 rg"), "must not emit white fill rgb: {content}");
+ assert!(!content.contains("1 g"), "must not emit white fill gray: {content}");
+ assert!(!content.contains("re\nf") && !content.contains("re f"), "must not emit whiteout rect fill: {content}");
+
+ // Assert that the image's cm was cleanly updated to the new position
+ let moved_imgs = images::page_images(&doc, 0).unwrap();
+ assert_eq!(moved_imgs.len(), 1);
+ assert!(close(moved_imgs[0].rect, target_rect), "rect matches target: {:?}", moved_imgs[0].rect);
+ assert!(
+ content.contains(&format!("{} {} cm", moved_imgs[0].matrix[4] as i64, moved_imgs[0].matrix[5] as i64))
+ || content.contains("cm\n /Im0 Do")
+ || content.contains("cm /Im0 Do"),
+ "cm cleanly updated: {content}"
+ );
+}
diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs
index 642c84e670..d23f44550b 100644
--- a/crates/engine/src/copy_image.rs
+++ b/crates/engine/src/copy_image.rs
@@ -20,7 +20,14 @@ impl Document {
let editor = self.editor.as_ref()?;
let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]);
self.page_images(page).iter().rposition(|i| {
- inside(&i.rect)
+ let bounds = if let Some(clip) = i.clip_rect {
+ let r = [i.rect[0].max(clip[0]), i.rect[1].max(clip[1]), i.rect[2].min(clip[2]), i.rect[3].min(clip[3])];
+ if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { r } else { i.visible_rect.unwrap_or(i.rect) }
+ } else {
+ i.visible_rect.unwrap_or(i.rect)
+ };
+ inside(&bounds)
+ && i.contains(at)
&& match (i.is_form, i.object) {
(false, Some(_)) => true,
(true, Some(form)) => picture_in_form(&editor.cos, form).is_some(),
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 3df0a908d0..61d93c4817 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -86,6 +86,8 @@ pub enum ImageEdit {
name: String,
bytes: Arc>,
},
+ /// Apply an arbitrary 2D affine transform matrix in user space.
+ Transform([f64; 6]),
Delete,
/// Change the stacking order.
Arrange(ArrangeOrder),
@@ -2003,6 +2005,7 @@ impl Edit {
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
Edit::EditPageImage { change, .. } => Label::of(match change {
ImageEdit::Move(_) => "Move image",
+ ImageEdit::Transform(_) => "Transform image",
ImageEdit::Rotate(_) => "Rotate image",
ImageEdit::Crop(_) => "Crop image",
ImageEdit::Flip { .. } => "Flip image",
@@ -2508,6 +2511,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
let c = match change {
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
+ ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ed90eea411..4951cf8a1f 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -5074,6 +5074,13 @@ fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
assert_eq!((w, h), (3, 2));
assert_eq!(rgba, px.into_raw());
assert!(d.page_image_rgba(0, 1).is_err());
+
+ // When the image is cropped, page_image_at only hit-tests the visible cropped region:
+ let crop_keep = [r[0] + (r[2] - r[0]) * 0.25, r[1] + (r[3] - r[1]) * 0.25, r[0] + (r[2] - r[0]) * 0.75, r[1] + (r[3] - r[1]) * 0.75];
+ s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(crop_keep) }).unwrap();
+ let d = s.get(id).unwrap();
+ assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
+ assert_eq!(d.page_image_at(0, [r[0] + 0.1, r[1] + 0.1]), None, "cropped-away corner must not hit-test positive");
}
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 0f924cb5aa..ff44d69af5 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -65,6 +65,318 @@ fn texture_bytes(tex: &TextureHandle) -> usize {
rgba_bytes(w, h)
}
+use crate::edit_text_ui::ImageHandle;
+
+/// Extension trait on `egui::Painter` to draw polygons.
+pub trait PainterExt {
+ fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx;
+}
+
+impl PainterExt for egui::Painter {
+ fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx {
+ self.add(egui::Shape::convex_polygon(points, fill, stroke))
+ }
+}
+
+/// User-space selectable bounding box for an image object.
+/// If the image object has an active clipping path applied to it (the crop box),
+/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
+pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
+ if let Some(clip) = im.clip_rect {
+ let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
+ if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
+ return r;
+ }
+ }
+ im.visible_rect.unwrap_or(im.rect)
+}
+
+/// Screen-space selectable bounding box for an image object.
+/// If the image object has an active clipping path applied to it (the crop box),
+/// the final selectable `egui::Rect` is the mathematical intersection of the image's
+/// full CTM-derived rect and the clipping path's rect.
+pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
+ let full = xf.user_rect(info, page, im.rect.map(|v| v as f32));
+ if let Some(clip) = im.clip_rect {
+ let clip_r = xf.user_rect(info, page, clip.map(|v| v as f32));
+ let inter = full.intersect(clip_r);
+ if inter.is_positive() {
+ return inter;
+ }
+ }
+ if let Some(vr) = im.visible_rect {
+ let r = xf.user_rect(info, page, vr.map(|v| v as f32));
+ if r.is_positive() {
+ return r;
+ }
+ }
+ full
+}
+
+/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
+/// with 8 resize handles and rotated local axes.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ImageObb {
+ pub s00: Pos2,
+ pub s10: Pos2,
+ pub s11: Pos2,
+ pub s01: Pos2,
+ pub s_bc: Pos2,
+ pub s_rc: Pos2,
+ pub s_tc: Pos2,
+ pub s_lc: Pos2,
+ pub s_rot: Pos2,
+ pub screen_center: Pos2,
+ pub width: f32,
+ pub height: f32,
+ pub theta: f32,
+ pub u_axis: (f64, f64),
+ pub v_axis: (f64, f64),
+ pub is_rotated: bool,
+}
+
+impl ImageObb {
+ /// Calculates the 4 rotated corners manually using center, width, height, and rotation angle (θ).
+ /// Local axes: `u_axis = (cos θ, sin θ)` and `v_axis = (-sin θ, cos θ)`.
+ /// Places the 8 resize handles exactly at these rotated vertices and midpoints.
+ pub fn from_center_size_angle(center: Pos2, width: f32, height: f32, theta: f32) -> Self {
+ let cos_t = theta.cos();
+ let sin_t = theta.sin();
+ let u_axis = (cos_t as f64, sin_t as f64);
+ let v_axis = (-sin_t as f64, cos_t as f64);
+ let u_vec = vec2(cos_t, sin_t);
+ let v_vec = vec2(-sin_t, cos_t);
+ let hw = width * 0.5;
+ let hh = height * 0.5;
+
+ let s00 = center - u_vec * hw - v_vec * hh;
+ let s10 = center + u_vec * hw - v_vec * hh;
+ let s11 = center + u_vec * hw + v_vec * hh;
+ let s01 = center - u_vec * hw + v_vec * hh;
+
+ let s_bc = center - v_vec * hh;
+ let s_rc = center + u_vec * hw;
+ let s_tc = center + v_vec * hh;
+ let s_lc = center - u_vec * hw;
+
+ let s_rot = s_tc + v_vec * 24.0;
+ let is_rotated = theta.abs() > 1e-4;
+
+ Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center: center, width, height, theta, u_axis, v_axis, is_rotated }
+ }
+
+ pub fn new(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
+ Self::from_matrix(matrix, xf, info, page)
+ }
+
+ pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
+ let m = crate::edit_text_ui::Matrix(matrix);
+ let p00 = m.apply(0.0, 0.0);
+ let p10 = m.apply(1.0, 0.0);
+ let p11 = m.apply(1.0, 1.0);
+ let p01 = m.apply(0.0, 1.0);
+
+ let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1);
+ let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1);
+ let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1);
+ let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1);
+
+ let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5);
+ let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5);
+ let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5);
+ let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5);
+
+ let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25);
+ let out_dir = s_tc - screen_center;
+ let out_len = out_dir.length().max(1e-4);
+ let out_norm = out_dir / out_len;
+ let s_rot = s_tc + out_norm * 24.0;
+
+ let [a, b, c, d, ..] = matrix;
+ let w_u = (a * a + b * b).sqrt().max(1e-6);
+ let h_v = (c * c + d * d).sqrt().max(1e-6);
+ let u_axis = (a / w_u, b / w_u);
+ let v_axis = (c / h_v, d / h_v);
+ let theta = (b).atan2(a) as f32;
+ let is_rotated = b.abs() > 1e-4 || c.abs() > 1e-4;
+
+ Self {
+ s00,
+ s10,
+ s11,
+ s01,
+ s_bc,
+ s_rc,
+ s_tc,
+ s_lc,
+ s_rot,
+ screen_center,
+ width: (s10 - s00).length(),
+ height: (s01 - s00).length(),
+ theta,
+ u_axis,
+ v_axis,
+ is_rotated,
+ }
+ }
+
+ pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self {
+ let [_, b, c, ..] = im.matrix;
+ let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4;
+ let bounds = image_selectable_bounds(im);
+ let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] {
+ [bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]]
+ } else {
+ im.matrix
+ };
+ Self::from_matrix(matrix, xf, info, page)
+ }
+
+ pub fn handles(&self) -> [(ImageHandle, Pos2); 8] {
+ [
+ (ImageHandle::TopLeft, self.s01),
+ (ImageHandle::TopCenter, self.s_tc),
+ (ImageHandle::TopRight, self.s11),
+ (ImageHandle::RightCenter, self.s_rc),
+ (ImageHandle::BottomRight, self.s10),
+ (ImageHandle::BottomCenter, self.s_bc),
+ (ImageHandle::BottomLeft, self.s00),
+ (ImageHandle::LeftCenter, self.s_lc),
+ ]
+ }
+
+ pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 {
+ match handle {
+ ImageHandle::TopLeft => self.s01,
+ ImageHandle::TopCenter => self.s_tc,
+ ImageHandle::TopRight => self.s11,
+ ImageHandle::RightCenter => self.s_rc,
+ ImageHandle::BottomRight => self.s10,
+ ImageHandle::BottomCenter => self.s_bc,
+ ImageHandle::BottomLeft => self.s00,
+ ImageHandle::LeftCenter => self.s_lc,
+ }
+ }
+
+ pub fn handle_id(&self, base_id: egui::Id, handle: ImageHandle) -> egui::Id {
+ base_id.with(match handle {
+ ImageHandle::TopLeft => "handle_tl",
+ ImageHandle::TopCenter => "handle_tc",
+ ImageHandle::TopRight => "handle_tr",
+ ImageHandle::RightCenter => "handle_rc",
+ ImageHandle::BottomRight => "handle_br",
+ ImageHandle::BottomCenter => "handle_bc",
+ ImageHandle::BottomLeft => "handle_bl",
+ ImageHandle::LeftCenter => "handle_lc",
+ })
+ }
+
+ pub fn handles_with_ids(&self, base_id: egui::Id) -> [(ImageHandle, Pos2, egui::Id); 8] {
+ [
+ (ImageHandle::TopLeft, self.s01, self.handle_id(base_id, ImageHandle::TopLeft)),
+ (ImageHandle::TopCenter, self.s_tc, self.handle_id(base_id, ImageHandle::TopCenter)),
+ (ImageHandle::TopRight, self.s11, self.handle_id(base_id, ImageHandle::TopRight)),
+ (ImageHandle::RightCenter, self.s_rc, self.handle_id(base_id, ImageHandle::RightCenter)),
+ (ImageHandle::BottomRight, self.s10, self.handle_id(base_id, ImageHandle::BottomRight)),
+ (ImageHandle::BottomCenter, self.s_bc, self.handle_id(base_id, ImageHandle::BottomCenter)),
+ (ImageHandle::BottomLeft, self.s00, self.handle_id(base_id, ImageHandle::BottomLeft)),
+ (ImageHandle::LeftCenter, self.s_lc, self.handle_id(base_id, ImageHandle::LeftCenter)),
+ ]
+ }
+
+ pub fn corners(&self) -> [Pos2; 4] {
+ [self.s00, self.s10, self.s11, self.s01]
+ }
+
+ /// Resizes the OBB proportionally along its local axes using projected mouse delta,
+ /// locking the diagonally opposite corner as the fixed anchor point.
+ pub fn scale_proportional(&self, handle: ImageHandle, delta: Vec2, min_size: f32) -> Self {
+ let u_vec = vec2(self.u_axis.0 as f32, self.u_axis.1 as f32);
+ let v_vec = vec2(self.v_axis.0 as f32, self.v_axis.1 as f32);
+ let delta_u = delta.dot(u_vec);
+ let delta_v = delta.dot(v_vec);
+ let min_s = min_size.max(5.0);
+
+ match handle {
+ ImageHandle::TopRight => {
+ let anchor = self.s00;
+ let s_u = (self.width + delta_u) / self.width.max(1e-4);
+ let s_v = (self.height + delta_v) / self.height.max(1e-4);
+ let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
+ let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
+ let nw = self.width * s;
+ let nh = self.height * s;
+ let new_center = anchor + u_vec * (nw * 0.5) + v_vec * (nh * 0.5);
+ Self::from_center_size_angle(new_center, nw, nh, self.theta)
+ }
+ ImageHandle::TopLeft => {
+ let anchor = self.s10;
+ let s_u = (self.width - delta_u) / self.width.max(1e-4);
+ let s_v = (self.height + delta_v) / self.height.max(1e-4);
+ let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
+ let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
+ let nw = self.width * s;
+ let nh = self.height * s;
+ let new_center = anchor - u_vec * (nw * 0.5) + v_vec * (nh * 0.5);
+ Self::from_center_size_angle(new_center, nw, nh, self.theta)
+ }
+ ImageHandle::BottomLeft => {
+ let anchor = self.s11;
+ let s_u = (self.width - delta_u) / self.width.max(1e-4);
+ let s_v = (self.height - delta_v) / self.height.max(1e-4);
+ let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
+ let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
+ let nw = self.width * s;
+ let nh = self.height * s;
+ let new_center = anchor - u_vec * (nw * 0.5) - v_vec * (nh * 0.5);
+ Self::from_center_size_angle(new_center, nw, nh, self.theta)
+ }
+ ImageHandle::BottomRight => {
+ let anchor = self.s01;
+ let s_u = (self.width + delta_u) / self.width.max(1e-4);
+ let s_v = (self.height - delta_v) / self.height.max(1e-4);
+ let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
+ let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
+ let nw = self.width * s;
+ let nh = self.height * s;
+ let new_center = anchor + u_vec * (nw * 0.5) - v_vec * (nh * 0.5);
+ Self::from_center_size_angle(new_center, nw, nh, self.theta)
+ }
+ ImageHandle::RightCenter => {
+ let anchor = self.s_lc;
+ let nw = (self.width + delta_u).max(min_s);
+ let new_center = anchor + u_vec * (nw * 0.5);
+ Self::from_center_size_angle(new_center, nw, self.height, self.theta)
+ }
+ ImageHandle::LeftCenter => {
+ let anchor = self.s_rc;
+ let nw = (self.width - delta_u).max(min_s);
+ let new_center = anchor - u_vec * (nw * 0.5);
+ Self::from_center_size_angle(new_center, nw, self.height, self.theta)
+ }
+ ImageHandle::TopCenter => {
+ let anchor = self.s_bc;
+ let nh = (self.height + delta_v).max(min_s);
+ let new_center = anchor + v_vec * (nh * 0.5);
+ Self::from_center_size_angle(new_center, self.width, nh, self.theta)
+ }
+ ImageHandle::BottomCenter => {
+ let anchor = self.s_tc;
+ let nh = (self.height - delta_v).max(min_s);
+ let new_center = anchor - v_vec * (nh * 0.5);
+ Self::from_center_size_angle(new_center, self.width, nh, self.theta)
+ }
+ }
+ }
+}
+
+/// Projects mouse delta onto local unit axes `u_axis` and `v_axis` using the dot product.
+pub fn project_delta(delta: Vec2, u_axis: (f64, f64), v_axis: (f64, f64)) -> (f64, f64) {
+ let dx = delta.x as f64;
+ let dy = delta.y as f64;
+ (dx * u_axis.0 + dy * u_axis.1, dx * v_axis.0 + dy * v_axis.1)
+}
+
/// Visible grid rows, plus one adjacent row in either direction. Work is independent of
/// document length, including after a large scroll jump.
pub(crate) fn thumbnail_rows(top: f32, bottom: f32, row_height: f32, rows: usize) -> Range {
@@ -606,15 +918,15 @@ pub(crate) fn default_page_context_menu(
*action = Some(PageMenuAction::Print);
ui.close();
}
- if crate::icons::button(ui, "mouse-pointer-2", 24.0, false, tl!("Select Tool")).clicked() {
+ if crate::icons::button(ui, "mouse-pointer-2", 24.0, false, "Select Tool").clicked() {
*action = Some(PageMenuAction::SelectTool);
ui.close();
}
- if crate::icons::button(ui, "hand", 24.0, false, tl!("Hand Tool")).clicked() {
+ if crate::icons::button(ui, "hand", 24.0, false, "Hand Tool").clicked() {
*action = Some(PageMenuAction::HandTool);
ui.close();
}
- if crate::icons::button(ui, "zoom-in", 24.0, false, tl!("Marquee Zoom")).clicked() {
+ if crate::icons::button(ui, "zoom-in", 24.0, false, "Marquee Zoom").clicked() {
*action = Some(PageMenuAction::MarqueeZoom);
ui.close();
}
@@ -626,7 +938,7 @@ pub(crate) fn default_page_context_menu(
*action = Some(PageMenuAction::Copy);
ui.close();
}
- if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() {
+ if ui.add_enabled(can_paste, egui::Button::new("Paste").shortcut_text("Ctrl+V")).clicked() {
*action = Some(PageMenuAction::Paste(page, at));
ui.close();
}
@@ -648,7 +960,7 @@ pub(crate) fn default_page_context_menu(
ui.separator();
- if ui.add(egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())).clicked() {
+ if ui.add(egui::Button::new(egui::RichText::new("Edit this PDF").strong())).clicked() {
*action = Some(PageMenuAction::EditPdf);
ui.close();
}
@@ -664,14 +976,14 @@ pub(crate) fn default_page_context_menu(
*action = Some(PageMenuAction::DeletePage(page));
ui.close();
}
- if ui.button(tl!("Export this PDF")).clicked() {
+ if ui.button("Export this PDF").clicked() {
*action = Some(PageMenuAction::ExportPdf);
ui.close();
}
ui.separator();
- if ui.button(tl!("Find text or tools")).clicked() {
+ if ui.button("Find text or tools").clicked() {
*action = Some(PageMenuAction::Find);
ui.close();
}
@@ -2187,6 +2499,100 @@ impl PageXform {
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
+
+ /// Draw a sub-region of a texture covering the normalised page region.
+ #[allow(clippy::too_many_arguments)]
+ pub fn paint_image_region(
+ &self,
+ painter: &egui::Painter,
+ tex: egui::TextureId,
+ pu0: f32,
+ pv0: f32,
+ pu1: f32,
+ pv1: f32,
+ tu0: f32,
+ tv0: f32,
+ tu1: f32,
+ tv1: f32,
+ ) {
+ let mut mesh = egui::Mesh::with_texture(tex);
+ let corners = [(pu0, pv0, tu0, tv0), (pu1, pv0, tu1, tv0), (pu1, pv1, tu1, tv1), (pu0, pv1, tu0, tv1)];
+ for (u, v, tu, tv) in corners {
+ mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(u, v), uv: pos2(tu, tv), color: Color32::WHITE });
+ }
+ mesh.add_triangle(0, 1, 2);
+ mesh.add_triangle(0, 2, 3);
+ painter.add(egui::Shape::mesh(mesh));
+ }
+
+ /// Draw a sub-rectangle of a whole-page raster texture, where UV space matches page normalized space.
+ pub fn paint_page_texture_rect(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) {
+ self.paint_image_region(painter, tex, u0, v0, u1, v1, u0, v0, u1, v1);
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn paint_image_suppressed(
+ xf: &PageXform,
+ painter: &egui::Painter,
+ tex: egui::TextureId,
+ u0: f32,
+ v0: f32,
+ u1: f32,
+ v1: f32,
+ suppress: Option<[f32; 4]>,
+ is_full_page_tex: bool,
+) {
+ let Some([su0, sv0, su1, sv1]) = suppress else {
+ if is_full_page_tex {
+ xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
+ } else {
+ xf.paint_image(painter, tex, u0, v0, u1, v1);
+ }
+ return;
+ };
+ let iu0 = u0.max(su0);
+ let iu1 = u1.min(su1);
+ let iv0 = v0.max(sv0);
+ let iv1 = v1.min(sv1);
+ if iu0 >= iu1 || iv0 >= iv1 {
+ if is_full_page_tex {
+ xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
+ } else {
+ xf.paint_image(painter, tex, u0, v0, u1, v1);
+ }
+ return;
+ }
+ let map_uv = |pu: f32, pv: f32| -> (f32, f32) {
+ if is_full_page_tex {
+ (pu, pv)
+ } else {
+ let du = (u1 - u0).max(1e-6);
+ let dv = (v1 - v0).max(1e-6);
+ ((pu - u0) / du, (pv - v0) / dv)
+ }
+ };
+ let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| {
+ if pu1 <= pu0 || pv1 <= pv0 {
+ return;
+ }
+ let (tu0, tv0) = map_uv(pu0, pv0);
+ let (tu1, tv1) = map_uv(pu1, pv1);
+ xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1);
+ };
+
+ if iv0 > v0 {
+ draw_strip(u0, v0, u1, iv0);
+ }
+ if v1 > iv1 {
+ draw_strip(u0, iv1, u1, v1);
+ }
+ if iu0 > u0 {
+ draw_strip(u0, iv0, iu0, iv1);
+ }
+ if u1 > iu1 {
+ draw_strip(iu1, iv0, u1, iv1);
+ }
}
pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) {
@@ -2516,11 +2922,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
// scroll the page just shown away from its top (or bottom).
view.precise_scroll_until = now + 0.25;
+ view.fling.stop();
}
+ let gesture_turned = !view.continuous && view.wheel.gesture_turned();
// Paging owns the vertical axis in single-page view; leave it to the pager.
// The rest of a gesture that turned the page is swallowed too, or it would pan the new page
// away from the edge it was shown at.
- if single_paging || (!view.continuous && view.wheel.gesture_turned()) {
+ if single_paging || gesture_turned {
precise_scroll.y = 0.0;
}
if now < view.precise_scroll_until {
@@ -2533,7 +2941,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it.
// Asked of the preference, not of egui's animation time, which test harnesses zero.
let animates = app.animation_effects.animates(app.motion.reduced);
- let may_glide = !single_paging && !middle_gesture && navigation_enabled && unobstructed && animates;
+ let may_glide = !single_paging && !gesture_turned && !middle_gesture && navigation_enabled && unobstructed && animates;
let over_page = ui.rect_contains_pointer(avail);
ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page));
let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })));
@@ -2741,6 +3149,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let mut visible_now = Vec::new();
view.screen_rects.clear();
view.screen_xforms.clear();
+ for &i in &visible_pages {
+ let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp);
+ if r.intersects(visible) {
+ view.screen_rects.push((i, r));
+ view.screen_xforms
+ .push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }));
+ }
+ }
// Visible heights (points) closer than this are equal: one page size can meet the
// viewport a fraction of a point differently once the scroll offset is applied.
const TIE: f32 = 0.5;
@@ -2757,6 +3173,37 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if resp.secondary_clicked() {
// Set again below when the right-click lands on a link.
view.links.context = None;
+ let mut over_image = false;
+ if tool == QuickTool::EditText {
+ let pointer_pos = ui.input(|inp| inp.pointer.hover_pos().or(inp.pointer.interact_pos()));
+ if let Some(p) = pointer_pos {
+ for (page_idx, xf) in &view.screen_xforms {
+ let images = doc.page_images(*page_idx);
+ for (idx, img) in images.iter().enumerate() {
+ let obb = ImageObb::from_image(img, xf, info, *page_idx);
+ let b = image_screen_rect(img, xf, info, *page_idx);
+ if (b.is_positive() && b.contains(p)) || crate::edit_text_ui::quad_contains(obb.corners(), p) {
+ over_image = true;
+ if view.image_selection.as_ref().is_none_or(|s| s.page != *page_idx || s.index != idx) {
+ view.image_selection = Some(crate::edit_text_ui::ImageSelection {
+ page: *page_idx,
+ index: idx,
+ drag: None,
+ crop: None,
+ crop_mode: false,
+ crop_rect: None,
+ });
+ }
+ break;
+ }
+ }
+ if over_image {
+ break;
+ }
+ }
+ }
+ }
+ ui.ctx().data_mut(|d| d.insert_persisted(resp.id.with("image-menu"), over_image));
}
for &i in &visible_pages {
let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp);
@@ -2768,9 +3215,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
if r.intersects(visible) {
visible_now.push(i);
- view.screen_rects.push((i, r));
- view.screen_xforms
- .push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }));
}
let overlap = r.intersect(visible).height();
if i == view.current {
@@ -2819,13 +3263,42 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
Some(p) => wanted.push((i, tag_scale(p.tag), p.tag, None)),
None => wanted.push((i, want_scale, want_tag, None)),
}
+ let active_drag_suppress = if let Some(sel) = view.image_selection.as_ref()
+ && sel.page == i
+ && sel.drag.is_some()
+ {
+ let images = doc.page_images(i);
+ if let Some(img) = images.get(sel.index) {
+ let obb = ImageObb::from_image(img, &xf, info, i);
+ let uvs = obb.corners().map(|c| xf.screen_to_norm(c));
+ let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0);
+ let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0);
+ let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0);
+ let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0);
+ if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
match view.pages.get(&i) {
Some(p) => {
if !tiled && p.tag == want_tag {
- xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
+ paint_image_suppressed(
+ &xf.texel_aligned(p.tex.size(), ppp),
+ painter,
+ p.tex.id(),
+ 0.0,
+ 0.0,
+ 1.0,
+ 1.0,
+ active_drag_suppress,
+ true,
+ );
} else {
// A backdrop, or a raster at an older scale until the new one arrives.
- xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
+ paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
}
}
None => {
@@ -2864,7 +3337,17 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32);
let [w, h] = tex.size();
let (x, y) = (ox as f32, oy as f32);
- xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh);
+ paint_image_suppressed(
+ &xf,
+ painter,
+ tex.id(),
+ x / ow,
+ y / oh,
+ (x + w as f32) / ow,
+ (y + h as f32) / oh,
+ active_drag_suppress,
+ false,
+ );
}
// Visible tiles, nearest the viewport centre first: each tile costs a full
// page interpret, so the workers sharpen what is looked at before the edges.
@@ -2889,13 +3372,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
wanted.push((i, scale, tag, Some(Tile { x, y, w, h })));
if let Some(tex) = view.tiles.get(&(i, tag, x, y)) {
let (fw, fh) = (dw as f32, dh as f32);
- xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image(
+ paint_image_suppressed(
+ &xf.texel_aligned([dw as usize, dh as usize], ppp),
painter,
tex.id(),
x as f32 / fw,
y as f32 / fh,
(x + w) as f32 / fw,
(y + h) as f32 / fh,
+ active_drag_suppress,
+ false,
);
}
}
@@ -3531,10 +4017,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
return;
}
- if tool == QuickTool::EditText
- && ui.ctx().data(|d| d.get_temp::(resp.id.with("image-menu"))).unwrap_or(false)
- && let Some(sel) = view.image_selection.clone()
- {
+ let is_image_menu = tool == QuickTool::EditText
+ && (ui.ctx().data_mut(|d| d.get_persisted::(resp.id.with("image-menu"))).unwrap_or(false)
+ || view.image_selection.as_ref().is_some_and(|sel| {
+ let pointer_pos = ui.input(|inp| inp.pointer.hover_pos().or(inp.pointer.interact_pos()));
+ let Some(p) = pointer_pos else { return false };
+ let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page) else { return false };
+ let images = doc.page_images(sel.page);
+ let Some(img) = images.get(sel.index) else { return false };
+ let obb = ImageObb::from_image(img, xf, info, sel.page);
+ let b = image_screen_rect(img, xf, info, sel.page);
+ (b.is_positive() && b.contains(p)) || crate::edit_text_ui::quad_contains(obb.corners(), p)
+ }));
+ if is_image_menu && let Some(sel) = view.image_selection.clone() {
let images = doc.page_images(sel.page);
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action);
return;
@@ -3900,14 +4395,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
Some(crate::edit_text_ui::ImageAction::Crop) => {
let doc_id = app.views[index].id;
- if let Some(s) = app.views[index].image_selection.as_mut() {
- if let Some(d) = app.session.get(doc_id) {
- let images = d.page_images(s.page);
- if let Some(im) = images.get(s.index) {
- s.crop = Some(crate::edit_text_ui::CropMode::new(im.visible_rect.unwrap_or(im.rect)));
- s.crop_mode = true;
- s.crop_rect = None;
- }
+ if let Some(s) = app.views[index].image_selection.as_mut()
+ && let Some(d) = app.session.get(doc_id)
+ {
+ let images = d.page_images(s.page);
+ if let Some(im) = images.get(s.index) {
+ s.crop = Some(crate::edit_text_ui::CropMode::new(im.visible_rect.unwrap_or(im.rect)));
+ s.crop_mode = true;
+ s.crop_rect = None;
}
}
}
@@ -5360,7 +5855,6 @@ trailer << /Root 1 0 R >>
}
assert_eq!(v.back, vec![0], "explicit search jump must remember its origin");
assert_eq!(v.goto, Some((3, 0.5)), "keep the match offset instead of the page top");
- assert_eq!(v.page_input, "4");
assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset");
v.go_to_match(1);
assert_eq!(v.back, vec![0], "same-page matches do not duplicate history");
@@ -6137,3 +6631,5 @@ trailer << /Root 1 0 R >>
assert!(v.goto.is_none());
}
}
+
+pub use crate::image_engine::{HandleId, MIN_RECT_SIZE, apply_crop, apply_resize, apply_resize_proportional, reset_crop};
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index c3388912b1..61be108256 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -7,7 +7,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign};
use pdfcraft_render::DocInfo;
-use crate::canvas::{DocView, PageXform};
+use crate::canvas::{DocView, ImageObb, PageXform, PainterExt};
use crate::theme::Tokens;
use crate::widgets;
@@ -447,10 +447,40 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform,
{
r = dragged(r, g, p - start, matches!(a.content, AddedContent::Image(_)));
}
- if selected {
+ let is_rotated_image = match &a.content {
+ AddedContent::Image(img) => img.rotation != 0,
+ _ => false,
+ };
+ if is_rotated_image {
+ let theta = match &a.content {
+ AddedContent::Image(img) => (img.rotation as f32) * std::f32::consts::FRAC_PI_2,
+ _ => 0.0,
+ };
+ let obb = ImageObb::from_center_size_angle(r.center(), r.width(), r.height(), theta);
+ if selected {
+ painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, SELECT_BLUE));
+ for (_, c) in obb.handles() {
+ painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
+ }
+ } else if pointer.is_some_and(|p| r.expand(2.0).contains(p)) {
+ painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)));
+ }
+ } else if selected {
painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.5, SELECT_BLUE), egui::StrokeKind::Outside);
- for (c, ..) in handles(r.expand(1.0)) {
- painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
+ let er = r.expand(1.0);
+ if matches!(a.content, AddedContent::Image(_)) {
+ let (tl, tr, br, bl) = (er.left_top(), er.right_top(), er.right_bottom(), er.left_bottom());
+ let tm = Pos2::new((tl.x + tr.x) * 0.5, tl.y);
+ let rm = Pos2::new(tr.x, (tr.y + br.y) * 0.5);
+ let bm = Pos2::new((bl.x + br.x) * 0.5, bl.y);
+ let lm = Pos2::new(tl.x, (tl.y + bl.y) * 0.5);
+ for c in [tl, tm, tr, rm, br, bm, bl, lm] {
+ painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
+ }
+ } else {
+ for (c, ..) in handles(er) {
+ painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
+ }
}
} else if pointer.is_some_and(|p| r.expand(2.0).contains(p)) {
painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside);
@@ -609,9 +639,9 @@ 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"));
+ widgets::section_title(ui, "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());
+ ui.label(egui::RichText::new("Standard fonts").weak().small());
for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] {
ui.selectable_value(&mut s.family, f, f.label());
}
@@ -628,17 +658,17 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) ->
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() {
+ if color_resp.on_hover_text("Text color").clicked() {
show_palette = !show_palette;
ui.data_mut(|d| d.insert_temp(show_palette_id, show_palette));
}
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() {
+ if crate::icons::button(ui, "list", 24.0, is_bullet, "Bullet list").clicked() {
crate::edit_text_ui::toggle_bullet_list(&mut s.text);
}
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() {
+ if crate::icons::button(ui, "list-ordered", 24.0, is_num, "Numbered list").clicked() {
crate::edit_text_ui::toggle_numbered_list(&mut s.text);
}
ui.add_space(2.0);
@@ -660,18 +690,18 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) ->
s.color = picked;
}
ui.horizontal(|ui| {
- let b_resp = ui.selectable_label(s.bold, egui::RichText::new("T").strong().size(14.0));
+ let b_resp = ui.selectable_label(s.bold, egui::RichText::new("B").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));
+ let i_resp = ui.selectable_label(s.italic, egui::RichText::new("I").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 = ui.selectable_label(false, egui::RichText::new("U̲").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() {
+ if sup_resp.on_hover_text("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()
@@ -680,7 +710,7 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) ->
};
}
let sub_resp = ui.selectable_label(false, egui::RichText::new("T₁").size(13.0));
- if sub_resp.on_hover_text(tl!("Subscript")).clicked() {
+ if sub_resp.on_hover_text("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()
@@ -787,7 +817,9 @@ impl crate::PdfCraftApp {
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 false; };
+ let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else {
+ return false;
+ };
let copied = pixels.and_then(|(w, h, rgba)| {
#[cfg(not(target_arch = "wasm32"))]
if self.system_clipboard {
@@ -813,7 +845,7 @@ impl crate::PdfCraftApp {
}
}
-/// The picture's visible box on its page, in user space.
+ /// The picture's visible box on its page, in user space.
fn page_image_box(&self, page: usize, index: usize) -> Option<[f64; 4]> {
let (_, id) = self.active_ids()?;
let image = self.session.get(id)?.page_images(page).into_iter().nth(index)?;
@@ -825,7 +857,10 @@ impl crate::PdfCraftApp {
if self.copy_page_image(page, index) {
self.apply_edit(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete });
for v in &mut self.views {
- if let Some(sel) = &v.image_selection && sel.page == page && sel.index == index {
+ if let Some(sel) = &v.image_selection
+ && sel.page == page
+ && sel.index == index
+ {
v.image_selection = None;
}
}
@@ -879,7 +914,7 @@ impl crate::PdfCraftApp {
if let Some(doc) = self.session.get(id)
&& !doc.allows_modification()
{
- self.notify_error(tl!("The document's security settings don't allow changes"));
+ self.notify_error("The document's security settings don't allow changes");
return false;
}
@@ -953,7 +988,8 @@ impl crate::PdfCraftApp {
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, crop: None });
+ v.image_selection =
+ Some(crate::edit_text_ui::ImageSelection { page, index: before, crop_mode: false, crop_rect: None, drag: None, crop: None });
}
self.quick_tool = crate::QuickTool::EditText;
self.notify_tr("Image pasted");
@@ -1065,20 +1101,81 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::
for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() {
ui.label(label);
// Applied when the drag ends, so a drag is one undo step.
- let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%"));
+ let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=95.0).speed(0.5).suffix("%"));
changed |= r.drag_stopped() || (r.changed() && !r.dragged());
}
});
if changed {
let mut i = img.clone();
- i.crop = crop.map(|v| v / 100.0);
- if i.crop != img.crop {
+ let new_crop = crop.map(|v| (v / 100.0).clamp(0.0, 0.95));
+ if new_crop != img.crop {
+ update_image_crop(&mut i, new_crop);
+ ui.ctx().request_repaint();
out = Some(ImageAction::Update(AddedContent::Image(i)));
}
}
out
}
+/// Update an added image's crop box and adjust its rect dimensions immediately.
+pub fn update_image_crop(img: &mut pdfcraft_engine::AddedImage, new_crop: [f64; 4]) {
+ let new_crop = [new_crop[0].clamp(0.0, 0.95), new_crop[1].clamp(0.0, 0.95), new_crop[2].clamp(0.0, 0.95), new_crop[3].clamp(0.0, 0.95)];
+ if new_crop != img.crop {
+ let [old_l, old_b, old_r, old_t] = img.crop.map(|v| v.clamp(0.0, 0.95));
+ let old_cw = (1.0 - old_l - old_r).max(0.05);
+ let old_ch = (1.0 - old_b - old_t).max(0.05);
+
+ let [new_l, new_b, new_r, new_t] = new_crop;
+ let new_cw = (1.0 - new_l - new_r).max(0.05);
+ let new_ch = (1.0 - new_b - new_t).max(0.05);
+
+ let [x0, y0, x1, y1] =
+ [img.rect[0].min(img.rect[2]), img.rect[1].min(img.rect[3]), img.rect[0].max(img.rect[2]), img.rect[1].max(img.rect[3])];
+ let current_w = x1 - x0;
+ let current_h = y1 - y0;
+
+ let base_w = current_w / old_cw;
+ let base_h = current_h / old_ch;
+
+ let new_w = base_w * new_cw;
+ let new_h = base_h * new_ch;
+
+ let (ux, uy, vx, vy): (f64, f64, f64, f64) = match img.rotation % 4 {
+ 1 => (0.0, 1.0, -1.0, 0.0),
+ 2 => (-1.0, 0.0, 0.0, -1.0),
+ 3 => (0.0, -1.0, 1.0, 0.0),
+ _ => (1.0, 0.0, 0.0, 1.0),
+ };
+ let (ux, uy) = if img.flip_h { (-ux, -uy) } else { (ux, uy) };
+ let (vx, vy) = if img.flip_v { (-vx, -vy) } else { (vx, vy) };
+
+ let base_ox = x0
+ - (base_w * old_l) * ux.max(0.0)
+ - (base_w * old_r) * (-ux).max(0.0)
+ - (base_h * old_b) * vx.max(0.0)
+ - (base_h * old_t) * (-vx).max(0.0);
+ let base_oy = y0
+ - (base_w * old_l) * uy.max(0.0)
+ - (base_w * old_r) * (-uy).max(0.0)
+ - (base_h * old_b) * vy.max(0.0)
+ - (base_h * old_t) * (-vy).max(0.0);
+
+ let new_ox = base_ox
+ + (base_w * new_l) * ux.max(0.0)
+ + (base_w * new_r) * (-ux).max(0.0)
+ + (base_h * new_b) * vx.max(0.0)
+ + (base_h * new_t) * (-vx).max(0.0);
+ let new_oy = base_oy
+ + (base_w * new_l) * uy.max(0.0)
+ + (base_w * new_r) * (-uy).max(0.0)
+ + (base_h * new_b) * vy.max(0.0)
+ + (base_h * new_t) * (-vy).max(0.0);
+
+ img.crop = new_crop;
+ img.rect = [new_ox, new_oy, new_ox + new_w, new_oy + new_h];
+ }
+}
+
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 753e7bac22..cab4849a94 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1183,7 +1183,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
}
Dialog::PinTool => {
ui.horizontal(|ui| {
- ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0)));
+ ui.label(egui::RichText::new("Pin tools to Home").font(theme::semibold(18.0)));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::ghost_button(ui, "x", tl!("Close")).clicked() {
close = true;
@@ -1191,18 +1191,16 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
});
});
ui.add_space(4.0);
- ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted));
+ ui.label(egui::RichText::new("Select tools to display on your Home page for quick access.").color(t.text_muted));
ui.add_space(10.0);
ui.horizontal(|ui| {
let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted);
ui.add(
- egui::TextEdit::singleline(&mut app.pin_tool_search)
- .hint_text(tl!("Search tools…"))
- .desired_width(ui.available_width() - 32.0),
+ egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text("Search tools…").desired_width(ui.available_width() - 32.0),
);
- if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() {
+ if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", "Clear search").clicked() {
app.pin_tool_search.clear();
}
});
@@ -1235,19 +1233,26 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
let body = rect.translate(press.offset());
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
- crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color);
+ crate::icons::paint(
+ ui,
+ egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)),
+ g.icon,
+ 20.0,
+ color,
+ );
let btn_w = 78.0;
- let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
+ let btn_rect =
+ egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
let btn_resp = ui.interact(btn_rect, ui.id().with(("pin_modal_btn", g.id)), egui::Sense::click());
let btn_hovered = btn_resp.hovered();
let (btn_fill, btn_stroke, text_color, btn_label) = if pinned {
- (t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned"))
+ (t.accent, egui::Stroke::NONE, egui::Color32::WHITE, "Pinned")
} else if btn_hovered {
- (t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin"))
+ (t.hover, egui::Stroke::new(1.0, t.border), t.text, "Pin")
} else {
- (egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin"))
+ (egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, "Pin")
};
ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside);
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index 7b16bbab8b..9129679c21 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -7,11 +7,12 @@
use std::collections::BTreeMap;
use std::sync::Arc;
-use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke};
+use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke, pos2};
use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
+use crate::image_engine::{CROP_ACCENT, HandleId, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds};
const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -495,8 +496,6 @@ pub fn toggle_subscript(ed: &mut LineEditor) -> bool {
}
}
-/// Underline, value-based line spacing, paragraph spacing, character spacing and horizontal
-
/// A paragraph being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct LineEditor {
@@ -728,6 +727,7 @@ pub enum DragTarget {
Move,
Handle(ImageHandle),
CropHandle(ImageHandle),
+ Rotate,
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -735,6 +735,7 @@ pub struct ImageDrag {
pub start: Pos2,
pub target: DragTarget,
pub initial_rect: Rect,
+ pub initial_matrix: [f64; 6],
}
/// A selected page image, and what the pointer is doing to it.
@@ -742,8 +743,8 @@ pub struct ImageDrag {
pub struct ImageSelection {
pub page: usize,
pub index: usize,
- /// Dragging: the start point and, for a corner, the opposite corner (screen).
- pub drag: Option<(Pos2, Option)>,
+ /// Dragging in progress.
+ pub drag: Option,
/// Crop mode (Acrobat's crop handles): the box kept so far.
pub crop: Option,
pub crop_mode: bool,
@@ -754,10 +755,9 @@ pub struct ImageSelection {
/// dimmed, Enter or Done applies and Esc or Cancel leaves it.
#[derive(Clone, Debug, PartialEq)]
pub struct CropMode {
- /// The box to keep (user space).
+ /// The box to keep (user space or normalized UV).
pub keep: [f64; 4],
- /// A drag in progress: the grip (0–3 corners, 4–7 edges, 8 the inside), where it started
- /// and the box then.
+ /// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then.
drag: Option<(usize, Pos2, [f64; 4])>,
}
@@ -767,8 +767,71 @@ impl CropMode {
}
}
-/// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left
-/// edge midpoints.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Matrix(pub(crate) [f64; 6]);
+
+impl Matrix {
+ #[allow(dead_code)]
+ pub(crate) const IDENTITY: Self = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
+
+ pub(crate) fn apply(&self, x: f64, y: f64) -> (f64, f64) {
+ let [a, b, c, d, e, f] = self.0;
+ (a * x + c * y + e, b * x + d * y + f)
+ }
+
+ pub(crate) fn invert(&self) -> Option {
+ let [a, b, c, d, e, f] = self.0;
+ let det = a * d - b * c;
+ if det.abs() < 1e-12 || !det.is_finite() {
+ return None;
+ }
+ let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
+ Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)]))
+ }
+
+ pub(crate) fn then(&self, then: &Matrix) -> Matrix {
+ let [a, b, c, d, e, f] = self.0;
+ let [a2, b2, c2, d2, e2, f2] = then.0;
+ Matrix([a * a2 + b * c2, a * b2 + b * d2, c * a2 + d * c2, c * b2 + d * d2, e * a2 + f * c2 + e2, e * b2 + f * d2 + f2])
+ }
+}
+
+pub(crate) fn user_to_screen(xf: &PageXform, info: &DocInfo, page: usize, x: f64, y: f64) -> Pos2 {
+ let Some(p) = info.pages.get(page) else { return Pos2::ZERO };
+ let v = p.user_to_view(x as f32, y as f32);
+ xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph)
+}
+
+fn screen_to_user(xf: &PageXform, info: &DocInfo, page: usize, pt: Pos2) -> (f64, f64) {
+ let (vx, vy) = xf.screen_to_view(pt);
+ let Some(p) = info.pages.get(page) else { return (0.0, 0.0) };
+ let u = p.view_to_user(vx, vy);
+ (f64::from(u[0]), f64::from(u[1]))
+}
+
+pub(crate) fn quad_contains(quad: [Pos2; 4], p: Pos2) -> bool {
+ let mut sign = 0;
+ for i in 0..4 {
+ let p1 = quad[i];
+ let p2 = quad[(i + 1) % 4];
+ let cross = (p2.x - p1.x) * (p.y - p1.y) - (p2.y - p1.y) * (p.x - p1.x);
+ if cross.abs() > 1e-4 {
+ let s = if cross > 0.0 { 1 } else { -1 };
+ if sign == 0 {
+ sign = s;
+ } else if sign != s {
+ return false;
+ }
+ }
+ }
+ true
+}
+
+pub use crate::canvas::{ImageObb, PainterExt, project_delta};
+
+/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true`
+/// (the pointer and keys are the crop's while it lasts).
+/// The screen positions of the crop's eight handles: corners, then the top, right, bottom and left edge midpoints.
fn crop_handles(k: Rect) -> [Pos2; 8] {
[k.left_top(), k.right_top(), k.left_bottom(), k.right_bottom(), k.center_top(), k.right_center(), k.center_bottom(), k.left_center()]
}
@@ -784,7 +847,7 @@ fn crop_input(
info: &DocInfo,
view: &mut DocView,
i: usize,
- b: Rect,
+ image: &pdfcraft_engine::PageImage,
allow_interaction: bool,
) -> bool {
let painter = ui.painter();
@@ -793,131 +856,239 @@ fn crop_input(
&& let Some(mode) = sel.crop.as_mut()
{
mode.keep = user_box(xf, info, page, rect);
+ sel.crop_rect = None;
}
let Some(mode) = sel.crop.as_mut() else { return false };
let hover_pos = ui.input(|inp| inp.pointer.hover_pos());
+ let full_b = xf.user_rect(info, page, image.rect.map(|v| v as f32));
+ let b = if full_b.is_positive() { full_b } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) };
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
- // A drag in progress moves a grip; the box stays inside the picture and at least 6 px.
+ let k = keep_screen(mode.keep);
+
if allow_interaction
&& resp.drag_started()
&& let Some(o) = ui.input(|inp| inp.pointer.press_origin())
{
- let k = keep_screen(mode.keep);
- let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8));
+ let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8));
if let Some(g) = grip {
mode.drag = Some((g, o, mode.keep));
}
}
+
if let Some((grip, start, at_start)) = mode.drag
&& let Some(p) = hover_pos
{
let d = p - start;
let k0 = keep_screen(at_start);
- let (mut min, mut max) = (k0.min, k0.max);
- match grip {
- 0 => {
- min.x += d.x;
- min.y += d.y;
- }
- 1 => {
- max.x += d.x;
- min.y += d.y;
- }
- 2 => {
- min.x += d.x;
- max.y += d.y;
- }
- 3 => {
- max.x += d.x;
- max.y += d.y;
- }
- 4 => min.y += d.y,
- 5 => max.x += d.x,
- 6 => max.y += d.y,
- 7 => min.x += d.x,
- _ => {
- let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y));
- min += shift;
- max += shift;
- }
+ if grip < 8 {
+ let handle_id = match grip {
+ 0 => HandleId::TopLeft,
+ 1 => HandleId::TopRight,
+ 2 => HandleId::BottomLeft,
+ 3 => HandleId::BottomRight,
+ 4 => HandleId::TopMid,
+ 5 => HandleId::RightMid,
+ 6 => HandleId::BottomMid,
+ _ => HandleId::LeftMid,
+ };
+ let uv0 = if b.width() > 0.0 && b.height() > 0.0 {
+ Rect::from_min_max(
+ pos2(((k0.min.x - b.min.x) / b.width()).clamp(0.0, 1.0), ((k0.min.y - b.min.y) / b.height()).clamp(0.0, 1.0)),
+ pos2(((k0.max.x - b.min.x) / b.width()).clamp(0.0, 1.0), ((k0.max.y - b.min.y) / b.height()).clamp(0.0, 1.0)),
+ )
+ } else {
+ Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))
+ };
+ let (new_screen, _) = apply_crop(k0, uv0, handle_id, d);
+ let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height()));
+ let clamped = Rect::from_min_max(
+ pos2(new_screen.min.x.clamp(b.min.x, b.max.x - min_w), new_screen.min.y.clamp(b.min.y, b.max.y - min_h)),
+ pos2(new_screen.max.x.clamp(b.min.x + min_w, b.max.x), new_screen.max.y.clamp(b.min.y + min_h, b.max.y)),
+ );
+ mode.keep = user_box(xf, info, page, clamped);
+ } else {
+ let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y));
+ mode.keep = user_box(xf, info, page, Rect::from_min_max(k0.min + shift, k0.max + shift));
}
- let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height()));
- min.x = min.x.clamp(b.min.x, b.max.x - min_w);
- min.y = min.y.clamp(b.min.y, b.max.y - min_h);
- max.x = max.x.clamp(min.x + min_w, b.max.x);
- max.y = max.y.clamp(min.y + min_h, b.max.y);
- mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max));
+
if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) {
mode.drag = None;
}
}
- let k = keep_screen(mode.keep);
- // What goes is dimmed; what stays keeps its frame and handles.
+
+ // Faint ghost outline of the full uncropped image underneath
+ painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgba_unmultiplied(200, 200, 200, 80)), egui::StrokeKind::Outside);
+
+ // Dim outer excluded bands
let dim = Color32::from_black_alpha(96);
- for r in [
- Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)),
- Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max),
- Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)),
- Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)),
- ] {
- if r.is_positive() {
- painter.rect_filled(r.intersect(xf.rect), CornerRadius::ZERO, dim);
- }
+ if k.min.y > b.min.y {
+ painter.rect_filled(Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)), CornerRadius::ZERO, dim);
}
- painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
- for h in crop_handles(k) {
- painter.rect(
- Rect::from_center_size(h, egui::vec2(8.0, 8.0)),
- CornerRadius::ZERO,
- Color32::WHITE,
- Stroke::new(1.0, ACCENT),
- egui::StrokeKind::Middle,
- );
+ if k.max.y < b.max.y {
+ painter.rect_filled(Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max), CornerRadius::ZERO, dim);
}
- // Done and Cancel, painted under the box (above it near the bottom of the view).
+ if k.min.x > b.min.x {
+ painter.rect_filled(Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)), CornerRadius::ZERO, dim);
+ }
+ if k.max.x < b.max.x {
+ painter.rect_filled(Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)), CornerRadius::ZERO, dim);
+ }
+
+ // Rule-of-Thirds Grid inside crop area
+ draw_rule_of_thirds(painter, k, Stroke::new(1.0, CROP_ACCENT.gamma_multiply(0.45)));
+
+ // Crop border stroke
+ painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, CROP_ACCENT), egui::StrokeKind::Outside);
+
+ // Crop handles
+ for (idx, h) in crop_handles(k).iter().enumerate() {
+ let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx);
+ let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0);
+ draw_handle(painter, *h, true, is_hovered, is_active, CROP_ACCENT);
+ }
+
+ // Action pills
let font = egui::FontId::proportional(12.5);
- let pill = |label: &str, right: f32, y: f32| -> Rect {
- let w = painter.layout_no_wrap(label.to_string(), font.clone(), Color32::WHITE).size().x + 20.0;
- Rect::from_min_size(Pos2::new(right - w, y), egui::vec2(w, 22.0))
+ let done_w = painter.layout_no_wrap(tl!("Done").to_string(), font.clone(), Color32::WHITE).size().x + 22.0;
+ let cancel_w = painter.layout_no_wrap(tl!("Cancel").to_string(), font.clone(), Color32::WHITE).size().x + 22.0;
+ let is_full_rect = (mode.keep[0] - image.rect[0]).abs() < 1e-3
+ && (mode.keep[1] - image.rect[1]).abs() < 1e-3
+ && (mode.keep[2] - image.rect[2]).abs() < 1e-3
+ && (mode.keep[3] - image.rect[3]).abs() < 1e-3;
+ let has_crop = !is_full_rect || image.visible_rect.is_some_and(|v| v != image.rect);
+ let reset_w =
+ if has_crop { Some(painter.layout_no_wrap(tl!("Remove Crop").to_string(), font.clone(), Color32::WHITE).size().x + 22.0) } else { None };
+ let spacing = 6.0;
+ let total_w = done_w + spacing + cancel_w + reset_w.map_or(0.0, |w| w + spacing);
+
+ // Position pills below crop box if there's room, otherwise above
+ let y = if k.max.y + 32.0 < xf.rect.max.y {
+ k.max.y + 6.0
+ } else if k.min.y - 28.0 > xf.rect.min.y {
+ k.min.y - 28.0
+ } else {
+ k.max.y + 6.0
};
- let y = if k.max.y + 30.0 < xf.rect.max.y { k.max.y + 6.0 } else { k.min.y - 28.0 };
- let done = pill(tl!("Done"), k.max.x, y);
- let cancel = pill(tl!("Cancel"), done.min.x - 6.0, y);
+
+ // Right-align pills with k.max.x, clamped inside the page bounds
+ let mut right = k.max.x.min(xf.rect.max.x - 8.0);
+ if right - total_w < xf.rect.min.x + 8.0 {
+ right = (xf.rect.min.x + 8.0 + total_w).min(xf.rect.max.x - 8.0);
+ }
+
+ let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0));
+ let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0));
+ let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0)));
+
+ let done_id = ui.id().with("crop_pill_done").with(page).with(i);
+ let cancel_id = ui.id().with("crop_pill_cancel").with(page).with(i);
+ let reset_id = ui.id().with("crop_pill_reset").with(page).with(i);
+
+ let done_resp = ui.interact(done, done_id, egui::Sense::click());
+ let cancel_resp = ui.interact(cancel, cancel_id, egui::Sense::click());
+ let reset_resp = reset.map(|r| ui.interact(r, reset_id, egui::Sense::click()));
+
let t = crate::theme::Tokens::get(ui.ctx());
- painter.rect_filled(done, CornerRadius::same(11), ACCENT);
+
+ // Done button (accent colored)
+ let done_bg = if done_resp.is_pointer_button_down_on() {
+ ACCENT.gamma_multiply(0.8)
+ } else if done_resp.hovered() {
+ Color32::from_rgb(40, 145, 255)
+ } else {
+ ACCENT
+ };
+ painter.rect_filled(done, CornerRadius::same(11), done_bg);
painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE);
- painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
- painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text);
+
+ // Cancel button (panel style with border)
+ let cancel_bg = if cancel_resp.is_pointer_button_down_on() {
+ t.pressed
+ } else if cancel_resp.hovered() {
+ t.hover
+ } else {
+ t.card
+ };
+ let cancel_stroke = Stroke::new(1.0, if cancel_resp.hovered() { ACCENT } else { t.border });
+ let cancel_text = if cancel_resp.hovered() { ACCENT } else { t.text };
+ painter.rect(cancel, CornerRadius::same(11), cancel_bg, cancel_stroke, egui::StrokeKind::Inside);
+ painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font.clone(), cancel_text);
+
+ // Reset button (panel style with border)
+ if let (Some(r), Some(r_resp)) = (reset, reset_resp.as_ref()) {
+ let reset_bg = if r_resp.is_pointer_button_down_on() {
+ t.pressed
+ } else if r_resp.hovered() {
+ t.hover
+ } else {
+ t.card
+ };
+ let reset_stroke = Stroke::new(1.0, if r_resp.hovered() { ACCENT } else { t.border });
+ let reset_text = if r_resp.hovered() { ACCENT } else { t.text };
+ painter.rect(r, CornerRadius::same(11), reset_bg, reset_stroke, egui::StrokeKind::Inside);
+ painter.text(r.center(), egui::Align2::CENTER_CENTER, tl!("Remove Crop"), font.clone(), reset_text);
+ }
+
if let Some(p) = hover_pos {
- let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) {
- match g {
- 0 | 3 => egui::CursorIcon::ResizeNwSe,
- 1 | 2 => egui::CursorIcon::ResizeNeSw,
- 4 | 6 => egui::CursorIcon::ResizeVertical,
- _ => egui::CursorIcon::ResizeHorizontal,
- }
- } else if done.contains(p) || cancel.contains(p) {
+ let icon = if done_resp.hovered()
+ || cancel_resp.hovered()
+ || reset_resp.as_ref().is_some_and(|r| r.hovered())
+ || done.contains(p)
+ || cancel.contains(p)
+ || reset.is_some_and(|r| r.contains(p))
+ {
egui::CursorIcon::PointingHand
+ } else if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) {
+ match g {
+ 0 => HandleId::TopLeft.cursor_icon(),
+ 1 => HandleId::TopRight.cursor_icon(),
+ 2 => HandleId::BottomLeft.cursor_icon(),
+ 3 => HandleId::BottomRight.cursor_icon(),
+ 4 => HandleId::TopMid.cursor_icon(),
+ 5 => HandleId::RightMid.cursor_icon(),
+ 6 => HandleId::BottomMid.cursor_icon(),
+ _ => HandleId::LeftMid.cursor_icon(),
+ }
} else if k.contains(p) {
- egui::CursorIcon::Move
+ if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
} else {
egui::CursorIcon::Default
};
ui.ctx().set_cursor_icon(icon);
}
- let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p));
+
let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input();
- if clicked(done) || keys(egui::Key::Enter) {
+ let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p))));
+ let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p))));
+ let reset_clicked = allow_interaction
+ && reset
+ .is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p))));
+
+ if reset_clicked {
+ mode.keep = image.rect;
+ mode.drag = None;
+ if let Some(s) = view.image_selection.as_mut() {
+ s.crop_rect = None;
+ }
+ } else if done_clicked || keys(egui::Key::Enter) {
let keep = mode.keep;
if let Some(s) = view.image_selection.as_mut() {
s.crop = None;
s.crop_mode = false;
s.crop_rect = None;
}
- if keep_screen(keep) != b {
+ let is_full = (keep[0] - image.rect[0]).abs() < 1e-3
+ && (keep[1] - image.rect[1]).abs() < 1e-3
+ && (keep[2] - image.rect[2]).abs() < 1e-3
+ && (keep[3] - image.rect[3]).abs() < 1e-3;
+ if is_full {
+ if image.visible_rect.is_some_and(|v| v != image.rect) {
+ view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop });
+ }
+ } else if image.visible_rect != Some(keep) {
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) });
}
- } else if (clicked(cancel) || keys(egui::Key::Escape))
+ } else if (cancel_clicked || keys(egui::Key::Escape))
&& let Some(s) = view.image_selection.as_mut()
{
s.crop = None;
@@ -937,14 +1108,15 @@ 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.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 };
- let Some(rect) = image.visible_rect else { return false };
- let b = xf.user_rect(info, page, rect.map(|v| v as f32));
+ let obb = ImageObb::from_image(image, xf, info, page);
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 Some(p) = p else { return false };
+
+ obb.handles().iter().any(|(_, h)| h.distance(p) < 8.0) || obb.s_rot.distance(p) < 8.0
}
/// What a drag on a paragraph box takes hold of.
@@ -1044,7 +1216,7 @@ pub(crate) fn image_input(
_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();
+ let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect();
let painter = ui.painter();
let hover_pos = ui.input(|i| i.pointer.hover_pos());
let image_contains = |index: usize, point: Pos2| {
@@ -1060,25 +1232,53 @@ pub(crate) fn image_input(
// Fully clipped placements (empty visible frames) get no outline.
let faint_all = boxes.len() <= MAX_OUTLINES;
if faint_all {
- for b in boxes.iter().filter(|b| b.is_positive()) {
- painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
+ for (idx, im) in images.iter().enumerate() {
+ if let Some(b) = boxes.get(idx).filter(|b| b.is_positive()) {
+ let obb = ImageObb::from_image(im, xf, info, page);
+ if obb.is_rotated {
+ let pts = obb.corners();
+ for k in 0..4 {
+ painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(0.75, ACCENT.gamma_multiply(0.35)));
+ }
+ } else {
+ painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
+ }
+ }
}
}
- let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p)));
+ let hovered_box = hover_pos.and_then(|p| {
+ boxes.iter().enumerate().rposition(|(i, b)| {
+ b.is_positive() && (b.contains(p) || quad_contains(ImageObb::from_image(&images[i], xf, info, page).corners(), p)) && image_contains(i, p)
+ })
+ });
if !faint_all {
for i in selected.into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) {
if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) {
- painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
+ let obb = ImageObb::from_image(&images[i], xf, info, page);
+ if obb.is_rotated {
+ let pts = obb.corners();
+ for k in 0..4 {
+ painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(0.75, ACCENT.gamma_multiply(0.35)));
+ }
+ } else {
+ painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
+ }
}
}
}
for i in selected.into_iter().chain(hovered_box).collect::>() {
let Some(b) = boxes.get(i).copied().filter(|b| b.is_positive()) else { continue };
+ let obb = ImageObb::from_image(&images[i], xf, info, page);
if selected == Some(i) {
- // The selected frame and handles are drawn below (or the drag preview instead).
if !dragging {
- painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
+ if obb.is_rotated {
+ painter.polygon(obb.corners().to_vec(), ACCENT.gamma_multiply(0.04), Stroke::NONE);
+ } else {
+ painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
+ }
}
+ } else if obb.is_rotated {
+ painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)));
} else {
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
}
@@ -1086,116 +1286,381 @@ pub(crate) fn image_input(
// The selected image: frame, corner handles, dragging.
if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) {
let b = boxes[i];
+ let image = &images[i];
+ let obb = ImageObb::from_image(image, xf, info, page);
+
if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some() || s.crop_mode) {
- if let Some(s) = view.image_selection.as_mut() && s.crop.is_none() {
+ if let Some(s) = view.image_selection.as_mut()
+ && s.crop.is_none()
+ {
let rect = images.get(i).and_then(|im| im.visible_rect).unwrap_or(images.get(i).map_or([0.0; 4], |im| im.rect));
s.crop = Some(CropMode::new(rect));
}
- return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction);
+ return crop_input(ui, resp, xf, page, info, view, i, image, allow_interaction);
}
- 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,
- );
- }
- };
+
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 obb.is_rotated {
+ painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, ACCENT));
+ } else {
+ painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
+ }
+ painter.line_segment([obb.s_tc, obb.s_rot], Stroke::new(1.2, ACCENT));
+ painter.circle(obb.s_rot, 4.5, Color32::WHITE, Stroke::new(1.0, ACCENT));
+ for (_, h) in obb.handles() {
+ let is_hovered = hover_pos.is_some_and(|p| p.distance(h) < 10.0);
+ draw_handle(painter, h, false, is_hovered, false, ACCENT);
+ }
}
- 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 mut handle_drag = None;
+ for (h, h_pos, handle_id) in obb.handles_with_ids(ui.id().with("img_handle")) {
+ let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0));
+ let handle_resp = ui.interact(handle_rect, handle_id, egui::Sense::drag());
+ if allow_interaction && handle_resp.drag_started() {
+ handle_drag = Some(h);
+ }
}
+ let rot_id = ui.id().with("img_handle").with(8);
+ let rot_rect = Rect::from_center_size(obb.s_rot, egui::vec2(12.0, 12.0));
+ let rot_resp = ui.interact(rot_rect, rot_id, egui::Sense::drag());
+ let rot_drag = allow_interaction && rot_resp.drag_started();
+
+ if allow_interaction && let Some(p) = hover_pos {
+ if obb.s_rot.distance(p) < 8.0 {
+ ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
+ } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(p) < 8.0) {
+ let icon = match h {
+ ImageHandle::TopLeft | ImageHandle::BottomRight => {
+ if obb.is_rotated {
+ let v = obb.handle_pos(*h) - obb.screen_center;
+ if v.x * v.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }
+ } else {
+ egui::CursorIcon::ResizeNwSe
+ }
+ }
+ ImageHandle::TopRight | ImageHandle::BottomLeft => {
+ if obb.is_rotated {
+ let v = obb.handle_pos(*h) - obb.screen_center;
+ if v.x * v.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }
+ } else {
+ egui::CursorIcon::ResizeNeSw
+ }
+ }
+ ImageHandle::TopCenter | ImageHandle::BottomCenter => {
+ if obb.is_rotated && (obb.v_axis.0.abs() > obb.v_axis.1.abs()) {
+ egui::CursorIcon::ResizeHorizontal
+ } else {
+ egui::CursorIcon::ResizeVertical
+ }
+ }
+ ImageHandle::RightCenter | ImageHandle::LeftCenter => {
+ if obb.is_rotated && (obb.u_axis.1.abs() > obb.u_axis.0.abs()) {
+ egui::CursorIcon::ResizeVertical
+ } else {
+ egui::CursorIcon::ResizeHorizontal
+ }
+ }
+ };
+ ui.ctx().set_cursor_icon(icon);
+ }
+ }
+
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 allow_interaction {
+ if let Some(h) = handle_drag {
if let Some(s) = view.image_selection.as_mut() {
- s.drag = Some((o, opposite));
+ let o = origin.unwrap_or_else(|| obb.handle_pos(h));
+ s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(h), initial_rect: b, initial_matrix: image.matrix });
+ }
+ } else if rot_drag {
+ if let Some(s) = view.image_selection.as_mut() {
+ let o = origin.unwrap_or(obb.s_rot);
+ s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix });
+ }
+ } else if resp.drag_started()
+ && let Some(o) = origin
+ {
+ if obb.s_rot.distance(o) < 8.0 {
+ if let Some(s) = view.image_selection.as_mut() {
+ s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix });
+ }
+ } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(o) < 8.0) {
+ if let Some(s) = view.image_selection.as_mut() {
+ s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(*h), initial_rect: b, initial_matrix: image.matrix });
+ }
+ } else if (image_contains(i, o) || quad_contains(obb.corners(), o))
+ && let Some(s) = view.image_selection.as_mut()
+ {
+ s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix });
}
}
}
- if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag)
+
+ 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))
- }
- 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() {
- // The pixels follow the frame: the page raster's picture of the image, stretched
- // into the preview over a blank where it was. Re-rendering every frame of a drag
- // would lag; the edit renders it properly on release.
- if preview != b
- && let Some(tex) = view.page_raster(page)
- {
- let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
- clipped.rect_filled(b, CornerRadius::ZERO, Color32::WHITE);
- let mut mesh = egui::Mesh::with_texture(tex);
- for (from, to) in [
- (b.left_top(), preview.left_top()),
- (b.right_top(), preview.right_top()),
- (b.right_bottom(), preview.right_bottom()),
- (b.left_bottom(), preview.left_bottom()),
+ if !obb.is_rotated && !matches!(drag.target, DragTarget::Rotate) {
+ let preview = match drag.target {
+ DragTarget::Handle(handle) => {
+ let handle_id = HandleId::from(handle);
+ let delta = p - drag.start;
+ let is_corner = matches!(handle_id, HandleId::TopLeft | HandleId::TopRight | HandleId::BottomLeft | HandleId::BottomRight);
+ let shift = ui.input(|inp| inp.modifiers.shift);
+ if is_corner && !shift {
+ apply_resize_proportional(drag.initial_rect, handle_id, delta)
+ } else {
+ apply_resize(drag.initial_rect, handle_id, delta)
+ }
+ }
+ DragTarget::Move => drag.initial_rect.translate(p - drag.start),
+ _ => drag.initial_rect,
+ };
+
+ 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 f_min = egui::vec2((raw.min.x - b.min.x) * factor.x, (raw.min.y - b.min.y) * factor.y);
+ let f_max = egui::vec2((raw.max.x - b.min.x) * factor.x, (raw.max.y - b.min.y) * factor.y);
+ let target = Rect::from_min_max(preview.min + f_min, preview.min + f_max);
+ let visible = preview.intersect(xf.rect);
+
+ if visible.is_positive() {
+ if preview != b
+ && let Some(tex) = view.page_raster(page)
+ {
+ let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
+ let mut mesh = egui::Mesh::with_texture(tex);
+ for (from, to) in [
+ (b.left_top(), preview.left_top()),
+ (b.right_top(), preview.right_top()),
+ (b.right_bottom(), preview.right_bottom()),
+ (b.left_bottom(), preview.left_bottom()),
+ ] {
+ let (u, v) = xf.screen_to_norm(from);
+ mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE });
+ }
+ mesh.add_triangle(0, 1, 2);
+ mesh.add_triangle(0, 2, 3);
+ clipped.add(egui::Shape::mesh(mesh));
+ }
+ 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);
+ for h in [
+ preview.left_top(),
+ preview.right_top(),
+ preview.left_bottom(),
+ preview.right_bottom(),
+ preview.center_top(),
+ preview.right_center(),
+ preview.center_bottom(),
+ preview.left_center(),
] {
- let (u, v) = xf.screen_to_norm(from);
- mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE });
+ draw_handle(painter, h, false, false, false, ACCENT);
}
- mesh.add_triangle(0, 1, 2);
- mesh.add_triangle(0, 2, 3);
- clipped.add(egui::Shape::mesh(mesh));
}
- 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(|inp| inp.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)) });
+ }
+ }
+
+ let icon = match drag.target {
+ DragTarget::Handle(handle) => HandleId::from(handle).cursor_icon(),
+ DragTarget::Move => egui::CursorIcon::Grabbing,
+ _ => egui::CursorIcon::Move,
+ };
+ ui.ctx().set_cursor_icon(icon);
+ return true;
}
- if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
+
+ let m_init = Matrix(drag.initial_matrix);
+ let [a0, b0, c0, d0, e0, f0] = drag.initial_matrix;
+ let w0 = (a0 * a0 + b0 * b0).sqrt().max(1e-6);
+ let h0 = (c0 * c0 + d0 * d0).sqrt().max(1e-6);
+ let u_axis = (a0 / w0, b0 / w0);
+ let v_axis = (c0 / h0, d0 / h0);
+ let center_user = (e0 + 0.5 * (a0 + c0), f0 + 0.5 * (b0 + d0));
+
+ let (sx, sy) = screen_to_user(xf, info, page, drag.start);
+ let (px, py) = screen_to_user(xf, info, page, p);
+
+ let (m_new, cursor_icon) = match drag.target {
+ DragTarget::Rotate => {
+ let v0 = (sx - center_user.0, sy - center_user.1);
+ let v1 = (px - center_user.0, py - center_user.1);
+ let theta0 = v0.1.atan2(v0.0);
+ let theta1 = v1.1.atan2(v1.0);
+ let mut delta = theta1 - theta0;
+ if ui.input(|i| i.modifiers.shift) {
+ let step = std::f64::consts::PI / 12.0;
+ delta = (delta / step).round() * step;
+ }
+ let (cos_d, sin_d) = (delta.cos(), delta.sin());
+ let t_rot = [
+ cos_d,
+ sin_d,
+ -sin_d,
+ cos_d,
+ center_user.0 * (1.0 - cos_d) + center_user.1 * sin_d,
+ center_user.1 * (1.0 - cos_d) - center_user.0 * sin_d,
+ ];
+ (m_init.then(&Matrix(t_rot)), egui::CursorIcon::Grab)
+ }
+ DragTarget::Move => {
+ let (dx, dy) = (px - sx, py - sy);
+ (Matrix([a0, b0, c0, d0, e0 + dx, f0 + dy]), egui::CursorIcon::Move)
+ }
+ DragTarget::Handle(handle) => {
+ let p00 = (e0, f0);
+ let p10 = (e0 + a0, f0 + b0);
+ let p11 = (e0 + a0 + c0, f0 + b0 + d0);
+ let p01 = (e0 + c0, f0 + d0);
+ let p_curr = (px, py);
+
+ let (new_w, new_h, p00_new, icon) = match handle {
+ ImageHandle::TopRight => {
+ let anchor = p00;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let du = d.0 * u_axis.0 + d.1 * u_axis.1;
+ let dv = d.0 * v_axis.0 + d.1 * v_axis.1;
+ let su = du / w0;
+ let sv = dv / h0;
+ let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv };
+ let s = s.max(5.0 / w0).max(5.0 / h0);
+ (w0 * s, h0 * s, anchor, egui::CursorIcon::ResizeNeSw)
+ }
+ ImageHandle::TopLeft => {
+ let anchor = p10;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let du = d.0 * (-u_axis.0) + d.1 * (-u_axis.1);
+ let dv = d.0 * v_axis.0 + d.1 * v_axis.1;
+ let su = du / w0;
+ let sv = dv / h0;
+ let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv };
+ let s = s.max(5.0 / w0).max(5.0 / h0);
+ let nw = w0 * s;
+ let nh = h0 * s;
+ (nw, nh, (anchor.0 - nw * u_axis.0, anchor.1 - nw * u_axis.1), egui::CursorIcon::ResizeNwSe)
+ }
+ ImageHandle::BottomLeft => {
+ let anchor = p11;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let du = d.0 * (-u_axis.0) + d.1 * (-u_axis.1);
+ let dv = d.0 * (-v_axis.0) + d.1 * (-v_axis.1);
+ let su = du / w0;
+ let sv = dv / h0;
+ let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv };
+ let s = s.max(5.0 / w0).max(5.0 / h0);
+ let nw = w0 * s;
+ let nh = h0 * s;
+ (
+ nw,
+ nh,
+ (anchor.0 - nw * u_axis.0 - nh * v_axis.0, anchor.1 - nw * u_axis.1 - nh * v_axis.1),
+ egui::CursorIcon::ResizeNeSw,
+ )
+ }
+ ImageHandle::BottomRight => {
+ let anchor = p01;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let du = d.0 * u_axis.0 + d.1 * u_axis.1;
+ let dv = d.0 * (-v_axis.0) + d.1 * (-v_axis.1);
+ let su = du / w0;
+ let sv = dv / h0;
+ let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv };
+ let s = s.max(5.0 / w0).max(5.0 / h0);
+ let nw = w0 * s;
+ let nh = h0 * s;
+ (nw, nh, (anchor.0 - nh * v_axis.0, anchor.1 - nh * v_axis.1), egui::CursorIcon::ResizeNwSe)
+ }
+ ImageHandle::RightCenter => {
+ let anchor = p00;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let du = (d.0 * u_axis.0 + d.1 * u_axis.1).max(5.0);
+ (du, h0, anchor, egui::CursorIcon::ResizeHorizontal)
+ }
+ ImageHandle::LeftCenter => {
+ let anchor = p10;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let du = (d.0 * (-u_axis.0) + d.1 * (-u_axis.1)).max(5.0);
+ (du, h0, (anchor.0 - du * u_axis.0, anchor.1 - du * u_axis.1), egui::CursorIcon::ResizeHorizontal)
+ }
+ ImageHandle::TopCenter => {
+ let anchor = p00;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let dv = (d.0 * v_axis.0 + d.1 * v_axis.1).max(5.0);
+ (w0, dv, anchor, egui::CursorIcon::ResizeVertical)
+ }
+ ImageHandle::BottomCenter => {
+ let anchor = p01;
+ let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1);
+ let dv = (d.0 * (-v_axis.0) + d.1 * (-v_axis.1)).max(5.0);
+ (w0, dv, (anchor.0 - dv * v_axis.0, anchor.1 - dv * v_axis.1), egui::CursorIcon::ResizeVertical)
+ }
+ };
+ let mat = Matrix([new_w * u_axis.0, new_w * u_axis.1, new_h * v_axis.0, new_h * v_axis.1, p00_new.0, p00_new.1]);
+ (mat, icon)
+ }
+ _ => (m_init, egui::CursorIcon::Default),
+ };
+
+ let preview_obb = ImageObb::new(m_new.0, xf, info, page);
+ if let Some(tex) = view.page_raster(page) {
+ let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
+ let mut mesh = egui::Mesh::with_texture(tex);
+ let orig_corners = obb.corners();
+ let new_corners = preview_obb.corners();
+ for (from, to) in [
+ (orig_corners[3], new_corners[3]),
+ (orig_corners[2], new_corners[2]),
+ (orig_corners[1], new_corners[1]),
+ (orig_corners[0], new_corners[0]),
+ ] {
+ let (u, v) = xf.screen_to_norm(from);
+ mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE });
+ }
+ mesh.add_triangle(0, 1, 2);
+ mesh.add_triangle(0, 2, 3);
+ clipped.add(egui::Shape::mesh(mesh));
+ }
+
+ if preview_obb.is_rotated {
+ let pts = preview_obb.corners();
+ painter.polygon(pts.to_vec(), ACCENT.gamma_multiply(0.04), Stroke::new(1.5, ACCENT));
+ } else {
+ let p_rect = Rect::from_two_pos(preview_obb.s01, preview_obb.s10);
+ let visible = p_rect.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);
+ }
+ }
+ for (_, h) in preview_obb.handles() {
+ draw_handle(painter, h, false, false, false, ACCENT);
+ }
+
+ if resp.drag_stopped() || !ui.input(|inp| inp.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)) });
+ if m_new.0 != drag.initial_matrix {
+ if matches!(drag.target, DragTarget::Move) {
+ let (dx, dy) = (px - sx, py - sy);
+ let new_rect = [image.rect[0] + dx, image.rect[1] + dy, image.rect[2] + dx, image.rect[3] + dy];
+ view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(new_rect) });
+ } else if let Some(inv) = Matrix(drag.initial_matrix).invert() {
+ let t = inv.then(&m_new);
+ view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Transform(t.0) });
+ }
}
}
- 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::Move,
- });
+ ui.ctx().set_cursor_icon(cursor_icon);
return true;
}
if allow_interaction
@@ -1214,26 +1679,34 @@ pub(crate) fn image_input(
// (a logo is smaller than its menu), and a menu that is not drawn for a frame cannot be clicked.
// Only a menu opened by a right-click on the picture: one opened over text or empty page while
// a picture stays selected is the page's own menu.
- let menu_key = resp.id.with("image-menu");
- if resp.secondary_clicked() {
- ui.ctx().data_mut(|d| d.insert_temp(menu_key, false));
- }
+ let menu_key = egui::Id::new("image-menu");
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 Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| {
+ b.is_positive() && (b.contains(p) || quad_contains(ImageObb::from_image(&images[i], xf, info, page).corners(), p)) && image_contains(i, p)
+ }) else {
if resp.secondary_clicked() || resp.clicked() {
view.image_selection = None;
}
return false;
};
+ let hit_obb = ImageObb::from_image(&images[hit], xf, info, page);
if selected != Some(hit) {
- painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
+ if hit_obb.is_rotated {
+ let pts = hit_obb.corners();
+ for k in 0..4 {
+ painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(1.5, ACCENT.gamma_multiply(0.7)));
+ }
+ } else {
+ painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
+ }
}
- let Some(b) = boxes.get(hit).copied() else { return false };
- let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0);
+ let corner = hit_obb.handles().iter().position(|(_, c)| c.distance(p) < 10.0);
ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) {
- (true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe,
- (true, Some(_)) => egui::CursorIcon::ResizeNeSw,
- (true, None) => egui::CursorIcon::Move,
+ (true, Some(0 | 4)) => egui::CursorIcon::ResizeNwSe,
+ (true, Some(2 | 6)) => egui::CursorIcon::ResizeNeSw,
+ (true, Some(1 | 5)) => egui::CursorIcon::ResizeVertical,
+ (true, Some(3 | 7)) => egui::CursorIcon::ResizeHorizontal,
+ (true, None) => egui::CursorIcon::Grab,
_ => egui::CursorIcon::PointingHand,
});
if resp.clicked() || resp.secondary_clicked() {
@@ -1265,7 +1738,7 @@ pub(crate) fn image_context_menu(
*action = Some(ImageAction::AddComment(page, hit));
ui.close();
}
- if crate::icons::button(ui, "crop", 24.0, false, tl!("Crop")).clicked() {
+ if crate::icons::button(ui, "crop", 24.0, false, "Crop").clicked() {
*action = Some(ImageAction::Crop);
if let Some(s) = view.image_selection.as_mut() {
let rect = img.visible_rect.unwrap_or(img.rect);
@@ -1296,7 +1769,7 @@ pub(crate) fn image_context_menu(
*action = Some(ImageAction::Copy(page, hit));
ui.close();
}
- if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste"))).clicked() {
+ if ui.add_enabled(can_paste, egui::Button::new("Paste")).clicked() {
*action = Some(ImageAction::Paste(page, img.rect));
ui.close();
}
@@ -1304,7 +1777,7 @@ pub(crate) fn image_context_menu(
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() {
+ if ui.add_enabled(is_cropped, egui::Button::new("Remove crop")).clicked() {
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::RemoveCrop });
ui.close();
}
@@ -1318,30 +1791,30 @@ pub(crate) fn image_context_menu(
ui.close();
}
- ui.menu_button(tl!("Arrange image"), |ui| {
+ ui.menu_button("Arrange image", |ui| {
let total = images.len();
- if ui.button(tl!("Bring to front")).clicked() {
+ if ui.button("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() {
+ if ui.button("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() {
+ if ui.button("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() {
+ if ui.button("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);
@@ -1369,7 +1842,7 @@ pub(crate) fn image_context_menu(
*action = Some(ImageAction::Redact(page, hit));
ui.close();
}
- if ui.add_enabled(raster, egui::Button::new(tl!("Export image"))).clicked() {
+ if ui.add_enabled(raster, egui::Button::new("Export image")).clicked() {
*action = Some(ImageAction::Save(page, hit));
ui.close();
}
@@ -1381,8 +1854,8 @@ pub(crate) fn image_context_menu(
ui.close();
}
- ui.menu_button(tl!("Edit image using"), |ui| {
- ui.add_enabled(false, egui::Button::new(tl!("Default image editor")));
+ ui.menu_button("Edit image using", |ui| {
+ ui.add_enabled(false, egui::Button::new("Default image editor"));
});
if ui.button(tl!("Recognize Text…")).clicked() {
@@ -2023,7 +2496,7 @@ mod tests {
assert_eq!(caret_color_on(DARK_EDITOR_FILL), Color32::WHITE);
}
-#[test]
+ #[test]
fn bullet_list_toggles_and_preserves_content() {
let mut text = "First line\nSecond line\n\nThird line".to_string();
assert!(!is_bulleted(&text));
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 5fdcd51ff3..cfebfc0d89 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -20,13 +20,7 @@ fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) {
/// Render a single tool card with the standard lift, styling, icon, title, description, and action.
/// If `allow_unpin` is true, an unpin button appears on hover in the top right corner.
-fn tool_card(
- app: &mut PdfCraftApp,
- ui: &mut egui::Ui,
- t: &Tokens,
- g: &'static catalog::ToolGroup,
- allow_unpin: bool,
-) -> Option<&'static str> {
+fn tool_card(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static catalog::ToolGroup, allow_unpin: bool) -> Option<&'static str> {
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
let press = theme::Press::track(ui, &resp);
@@ -41,40 +35,29 @@ fn tool_card(
if allow_unpin && (ui.rect_contains_pointer(rect) || resp.hovered()) {
let unpin_rect = Rect::from_center_size(body.right_top() + vec2(-18.0, 18.0), vec2(22.0, 22.0));
let unpin_resp = ui.interact(unpin_rect, ui.id().with(("unpin_card_btn", g.id)), Sense::click());
- unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Unpin tool")));
+ unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Unpin tool"));
if unpin_resp.hovered() {
ui.painter().rect_filled(unpin_rect, CornerRadius::same(4), t.pressed);
}
icons::paint(ui, unpin_rect.shrink(4.0), "x", 14.0, t.icon);
- if unpin_resp.on_hover_text(tl!("Unpin from Home")).clicked() {
+ if unpin_resp.on_hover_text("Unpin from Home").clicked() {
unpin_clicked = true;
}
}
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
- title_job.wrap =
- egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
+ title_job.wrap = egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
let title = ui.fonts_mut(|f| f.layout_job(title_job));
let title_elided = title.elided;
ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
- let blurb = g
- .sections
- .first()
- .map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · "))
- .unwrap_or_default();
+ let blurb = g.sections.first().map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · ")).unwrap_or_default();
// Fixed-height cards must leave room for their action in every language.
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
job.wrap.max_rows = 2;
let galley = ui.fonts_mut(|f| f.layout_job(job));
let blurb_elided = galley.elided;
ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
- ui.painter().text(
- body.left_bottom() + vec2(14.0, -14.0),
- Align2::LEFT_CENTER,
- tl!("Use now"),
- theme::medium(12.0),
- t.accent_text,
- );
+ ui.painter().text(body.left_bottom() + vec2(14.0, -14.0), Align2::LEFT_CENTER, tl!("Use now"), theme::medium(12.0), t.accent_text);
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
if unpin_clicked {
Some(g.id)
@@ -156,19 +139,16 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.add_space(26.0);
let mut unpin_tool = None;
ui.horizontal(|ui| {
- ui.label(egui::RichText::new(tl!("Pinned Tools")).font(theme::semibold(17.0)));
+ ui.label(egui::RichText::new("Pinned Tools").font(theme::semibold(17.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if widgets::ghost_button(ui, "pin", tl!("Pin a tool…")).clicked() {
+ if widgets::ghost_button(ui, "pin", "Pin a tool…").clicked() {
app.dialog = Some(crate::Dialog::PinTool);
}
});
});
ui.add_space(4.0);
if app.pinned_tools.is_empty() {
- ui.label(
- egui::RichText::new(tl!("Pin your favorite tools here for quick access."))
- .color(t.text_muted),
- );
+ ui.label(egui::RichText::new("Pin your favorite tools here for quick access.").color(t.text_muted));
} else {
ui.add_space(6.0);
let pinned = app.pinned_tools.clone();
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 8f2fea70e0..4a5828425d 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2345,7 +2345,7 @@ animation effects Off Изключени
Move tag later Преместване на етикета по-късно
Turn upside down (180°) Обърни наопаки (180°)
4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm)
- Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
+ Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната и област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
Find untagged content Намиране на немаркирано съдържание
No untagged content was found. Не е намерено немаркирано съдържание.
Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание.
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 548daf0caa..39e01892ff 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -1952,6 +1952,12 @@ action wizard Start Iniciar
Rotate image Girar imagen
Flip image Voltear imagen
Delete image Eliminar imagen
+ Bring to front Traer al frente
+ Send to back Enviar al fondo
+ Bring forward Traer adelante
+ Send backward Enviar atrás
+ Remove crop Quitar recorte
+ Crop image Recortar imagen
Add header & footer Añadir encabezado y pie de página
Update header & footer Actualizar encabezado y pie de página
Update watermark Actualizar marca de agua
diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv
index 247b6cff95..227d140b51 100644
--- a/crates/ui-egui/src/i18n/lt.tsv
+++ b/crates/ui-egui/src/i18n/lt.tsv
@@ -2672,3 +2672,11 @@ key Space Tarpas
Make properties default Nustatyti ypatybes kaip numatytąsias
Pages selected Pasirinkti puslapiai
Page properties Puslapio ypatybės
+ Bring forward Perkelti į priekį
+ Bring to front Iškelti į priekį
+ Crop image Apkarpyti paveikslėlį
+ Remove crop Pašalinti apkarpymą
+ Send backward Perkelti atgal
+ Send to back Nukelti į galą
+theme Appearance Išvaizda
+ The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai.
diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs
index 5bb2889c9d..4a960429fa 100644
--- a/crates/ui-egui/src/i18n/mod.rs
+++ b/crates/ui-egui/src/i18n/mod.rs
@@ -2498,6 +2498,7 @@ mod tests {
"JavaScript",
"Microsoft Word (.docx)",
"PDF/A…",
+ "DN: ",
"PostScript / EPS",
"Rich Text Format (.rtf)",
"CheckBox",
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index a01b47f8a1..fdfc7328ac 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -723,7 +723,7 @@ theme Appearance Giao diện
More options Thêm tùy chọn
More options: Thêm tùy chọn:
More ways to add files Thêm cách để thêm tệp
- Mouse wheel scroll speed Tốc độ cuộn bánh xe chuột
+ Scroll speed (mouse wheel and arrow keys) Tốc độ cuộn (bánh xe chuột và phím mũi tên)
Move down Chuyển xuống
Move earlier Chuyển lên trước
Move later Chuyển ra sau
@@ -1176,7 +1176,7 @@ theme Appearance Giao diện
This document isn't password-protected Tài liệu này không được bảo vệ bằng mật khẩu
This drawing exceeds the snapping limit; only the first paths are used. Bản vẽ này vượt quá giới hạn bắt dính; chỉ các đường đầu tiên được sử dụng.
This field is locked. Unlock it to change its properties. Trường này đã bị khóa. Hãy mở khóa để thay đổi thuộc tính của nó.
- This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Tệp này đã thay đổi trên đĩa. Hãy tải lại để xem phiên bản mới; các thay đổi chưa lưu của bạn sẽ bị mất.
+ This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tệp này đã thay đổi trên đĩa. Tải lại sẽ hiển thị phiên bản mới và hủy các thay đổi chưa lưu của bạn; Lưu sẽ thay thế bằng phiên bản này.
This file was damaged and has been repaired. Saving keeps the repaired version. Tệp này bị hỏng và đã được sửa chữa. Việc lưu sẽ giữ phiên bản đã sửa.
This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Thư mục này hiện không khả dụng. Nó có thể đã bị di chuyển, hoặc ổ đĩa hay đồng bộ đám mây của nó đang ngoại tuyến.
This form's scripts are turned off because one ran too long. Reopen the document to run them again. Các script của biểu mẫu này đã bị tắt vì một script chạy quá lâu. Hãy mở lại tài liệu để chạy chúng lại.
@@ -2615,3 +2615,70 @@ key Space Space
Redact Image Xóa nội dung nhạy cảm của hình ảnh
Create Link… Tạo liên kết…
Recognize Text… Nhận dạng văn bản…
+ Bring forward Chuyển lên trước
+ Bring to front Đưa lên trước cùng
+ Characters Ký tự
+ Characters without spaces Ký tự không tính dấu cách
+ Counting words… {n} pages read Đang đếm từ… {n} trang đã đọc
+ Crop Image Cắt hình ảnh
+ Crop image Cắt hình ảnh
+ Current page only Chỉ trang hiện tại
+ DN: DN:
+ Date: Ngày:
+ Digitally signed by Được ký kỹ thuật số bởi
+ Exclude image/OCR text Loại trừ văn bản hình ảnh/OCR
+ Exclude tagged code blocks Loại trừ khối mã có gắn thẻ
+ Exclude tagged tables Loại trừ bảng có gắn thẻ
+ Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Loại trừ sử dụng thẻ PDF. Bảng và mã chưa gắn thẻ vẫn được bao gồm. Việc loại trừ hình ảnh cũng xóa văn bản ẩn.
+ Fill colour: Màu tô:
+ Font size Cỡ chữ
+ Full Screen Toàn màn hình
+ Location: Địa điểm:
+ Make properties default Đặt thuộc tính làm mặc định
+ Mark as artifact Đánh dấu là phần bổ trợ
+ Mark content as artifact Đánh dấu nội dung là phần bổ trợ
+ Merge image fragments Hợp nhất các đoạn hình ảnh
+ Mobile Di động
+ New file only Chỉ tệp mới
+ New file: {name} Tệp mới: {name}
+ Next tab Tab tiếp theo
+ No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Không tìm thấy máy in nào. Hãy kiểm tra xem máy in đã được cài đặt và dịch vụ bộ đệm in đang chạy, rồi mở lại In.
+ Not now Không phải bây giờ
+ Old file: {name} Tệp cũ: {name}
+ Opacity: Độ mờ:
+ Open in Full Screen mode? Mở ở chế độ Toàn màn hình?
+ Optimizing… merging image fragments Đang tối ưu hóa… hợp nhất các đoạn hình ảnh
+ Outline colour: Màu viền:
+ Page properties Thuộc tính trang
+ Pages counted Số trang đã đếm
+ Pages selected Các trang đã chọn
+ Previous tab Tab trước
+ Print as image In dưới dạng hình ảnh
+ Reason: Lý do:
+ Redaction mark appearance Diện mạo dấu xóa nội dung nhạy cảm
+ Remove Crop Xóa cắt
+ Remove crop Xóa cắt
+ Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là phần bổ trợ
+ Resolution: Độ phân giải:
+ Save anyway Vẫn lưu
+ Save over the changed file? Lưu đè lên tệp đã thay đổi?
+ Send backward Chuyển về sau
+ Send to back Đưa về sau cùng
+ Set page transitions Đặt hiệu ứng chuyển trang
+ Shown while the pointer is over a mark. Hiển thị khi con trỏ ở trên một dấu.
+ Side by side Cạnh nhau
+ Some content could not be classified safely and was kept in the count. Một số nội dung không thể phân loại an toàn và đã được giữ lại trong số đếm.
+ Some pages contain no countable text. Scanned images need OCR first. Một số trang không chứa văn bản có thể đếm. Hình ảnh quét cần OCR trước.
+ Standard Tiêu chuẩn
+ The labels are in English: the signature's font can't draw them in this language. Các nhãn bằng tiếng Anh: phông chữ của chữ ký không thể vẽ chúng bằng ngôn ngữ này.
+ The password must have at least 8 characters. Mật khẩu phải có ít nhất 8 ký tự.
+ This document asks to open in Full Screen mode: the page fills the screen and nothing else shows. Press Esc at any time to leave it. Tài liệu này yêu cầu mở ở chế độ Toàn màn hình: trang lấp đầy màn hình và không hiển thị gì khác. Nhấn Esc bất kỳ lúc nào để thoát.
+ Trim the picture with its handles; Enter or Done applies Cắt hình ảnh bằng các tay cầm; nhấn Enter hoặc Xong để áp dụng
+ Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Dùng khi máy in bỏ qua văn bản, đổ bóng hoặc độ trong suốt. Bản in sẽ không còn là văn bản có thể tìm kiếm; với In thang độ xám, hình ảnh sẽ có màu xám.
+ What takes up the space in this file, by kind of content. Những gì chiếm dung lượng trong tệp này, theo loại nội dung.
+ Word count Đếm từ
+ Word counting stopped unexpectedly. Quá trình đếm từ đã dừng đột ngột.
+ Word count… Đếm từ…
+ Words Từ
+ “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi mở hoặc lưu lần cuối. Lưu bây giờ sẽ thay thế phiên bản mới hơn đó bằng phiên bản này.
+@id file.word_count Đếm từ…
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 0ea252e56c..2ddb33dd6d 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -787,7 +787,6 @@
Redaction code: 遮蔽代碼:
Font: 字型:
Font size: 字型大小:
- Font size: 字號:
Font size 字號
Auto-size text to fit redaction region 自動調整文字大小以適應遮蔽內容區域
Font colour: 字型顏色:
@@ -1322,7 +1321,6 @@
Sort comments by: 註解排序依據:
Create PDF Comment Summary 建立 PDF 註解摘要
Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到「{name}」的上次儲存版本?此後的更改將無法復原。
- Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到“{name}”的上次儲存版本?此後的更改將無法復原。
Save over the changed file? 覆寫已變更的檔案?
“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. 「{name}」在開啟或上次儲存後已在磁碟上變更。現在儲存會以此版本取代那個較新的版本。
Save anyway 仍要儲存
diff --git a/crates/ui-egui/src/image_engine.rs b/crates/ui-egui/src/image_engine.rs
new file mode 100644
index 0000000000..20e2d6e8ed
--- /dev/null
+++ b/crates/ui-egui/src/image_engine.rs
@@ -0,0 +1,645 @@
+//! Image manipulation engine for non-destructive crop, anchor-based resize, and movement.
+//!
+//! Ported and adapted from `Egui_image_resize_sandbox` with clean-room guarantees,
+//! zero-panic safety, and lockstep pixel-scale invariance.
+
+use egui::{Color32, CursorIcon, Pos2, Rect, Stroke, Vec2, pos2, vec2};
+
+use crate::edit_text_ui::ImageHandle;
+
+// ============================================================================
+// 1. HANDLE DEFINITION & GEOMETRIC ANCHORS
+// ============================================================================
+
+/// The 8 anchor handles for image resizing and cropping.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum HandleId {
+ TopLeft,
+ TopMid,
+ TopRight,
+ RightMid,
+ BottomRight,
+ BottomMid,
+ BottomLeft,
+ LeftMid,
+}
+
+impl HandleId {
+ /// Ordered list of all 8 handles for iteration.
+ pub const ALL: [HandleId; 8] = [
+ HandleId::TopLeft,
+ HandleId::TopMid,
+ HandleId::TopRight,
+ HandleId::RightMid,
+ HandleId::BottomRight,
+ HandleId::BottomMid,
+ HandleId::BottomLeft,
+ HandleId::LeftMid,
+ ];
+
+ /// Returns a human-readable display name.
+ pub fn name(&self) -> &'static str {
+ match self {
+ HandleId::TopLeft => "Top-Left",
+ HandleId::TopMid => "Top-Center",
+ HandleId::TopRight => "Top-Right",
+ HandleId::RightMid => "Right-Center",
+ HandleId::BottomRight => "Bottom-Right",
+ HandleId::BottomMid => "Bottom-Center",
+ HandleId::BottomLeft => "Bottom-Left",
+ HandleId::LeftMid => "Left-Center",
+ }
+ }
+
+ /// Evaluates the handle's current 2D screen position on a given bounding box.
+ pub fn position(&self, rect: Rect) -> Pos2 {
+ match self {
+ HandleId::TopLeft => rect.left_top(),
+ HandleId::TopMid => pos2(rect.center().x, rect.top()),
+ HandleId::TopRight => rect.right_top(),
+ HandleId::RightMid => pos2(rect.right(), rect.center().y),
+ HandleId::BottomRight => rect.right_bottom(),
+ HandleId::BottomMid => pos2(rect.center().x, rect.bottom()),
+ HandleId::BottomLeft => rect.left_bottom(),
+ HandleId::LeftMid => pos2(rect.left(), rect.center().y),
+ }
+ }
+
+ /// Mouse cursor to display when hovering or dragging this handle.
+ pub fn cursor_icon(&self) -> CursorIcon {
+ match self {
+ HandleId::TopLeft | HandleId::BottomRight => CursorIcon::ResizeNwSe,
+ HandleId::TopRight | HandleId::BottomLeft => CursorIcon::ResizeNeSw,
+ HandleId::TopMid | HandleId::BottomMid => CursorIcon::ResizeVertical,
+ HandleId::LeftMid | HandleId::RightMid => CursorIcon::ResizeHorizontal,
+ }
+ }
+
+ /// Returns a description of the fixed anchor locked during dragging.
+ pub fn anchor_description(&self) -> &'static str {
+ match self {
+ HandleId::TopLeft => "Bottom-Right point locked",
+ HandleId::TopMid => "Bottom edge locked",
+ HandleId::TopRight => "Bottom-Left point locked",
+ HandleId::RightMid => "Left edge locked",
+ HandleId::BottomRight => "Top-Left point locked",
+ HandleId::BottomMid => "Top edge locked",
+ HandleId::BottomLeft => "Top-Right point locked",
+ HandleId::LeftMid => "Right edge locked",
+ }
+ }
+}
+
+impl From for ImageHandle {
+ fn from(h: HandleId) -> Self {
+ match h {
+ HandleId::TopLeft => ImageHandle::TopLeft,
+ HandleId::TopMid => ImageHandle::TopCenter,
+ HandleId::TopRight => ImageHandle::TopRight,
+ HandleId::RightMid => ImageHandle::RightCenter,
+ HandleId::BottomRight => ImageHandle::BottomRight,
+ HandleId::BottomMid => ImageHandle::BottomCenter,
+ HandleId::BottomLeft => ImageHandle::BottomLeft,
+ HandleId::LeftMid => ImageHandle::LeftCenter,
+ }
+ }
+}
+
+impl From for HandleId {
+ fn from(h: ImageHandle) -> Self {
+ match h {
+ ImageHandle::TopLeft => HandleId::TopLeft,
+ ImageHandle::TopCenter => HandleId::TopMid,
+ ImageHandle::TopRight => HandleId::TopRight,
+ ImageHandle::RightCenter => HandleId::RightMid,
+ ImageHandle::BottomRight => HandleId::BottomRight,
+ ImageHandle::BottomCenter => HandleId::BottomMid,
+ ImageHandle::BottomLeft => HandleId::BottomLeft,
+ ImageHandle::LeftCenter => HandleId::LeftMid,
+ }
+ }
+}
+
+// ============================================================================
+// 2. MATHEMATICAL CORE: ANCHOR-BASED RESIZE & CROP
+// ============================================================================
+
+/// Minimum allowed dimensions in pixels to prevent panics, zero divisions, or inverted rects.
+pub const MIN_RECT_SIZE: f32 = 10.0;
+
+/// **Anchor-Based Resize System**
+///
+/// Recalculates the screen bounding box by locking the diagonally opposite point/edge as
+/// a fixed anchor.
+///
+/// Math breakdown:
+/// - Corner handles: The diagonally opposite corner is fixed in both X and Y.
+/// Width and height are calculated relative to that anchor, clamped to >= `MIN_RECT_SIZE`,
+/// and the new bounding box is constructed from `anchor` and the clamped dragged coordinate.
+/// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is
+/// completely unaffected.
+pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
+ if !delta.x.is_finite() || !delta.y.is_finite() {
+ return image_rect;
+ }
+ let mut min = image_rect.min;
+ let mut max = image_rect.max;
+
+ match handle {
+ // Dragging TopLeft: Fixed Anchor = BottomRight (max.x, max.y)
+ HandleId::TopLeft => {
+ let anchor = max;
+ let target = min + delta;
+ let new_w = (anchor.x - target.x).max(MIN_RECT_SIZE);
+ let new_h = (anchor.y - target.y).max(MIN_RECT_SIZE);
+ min.x = anchor.x - new_w;
+ min.y = anchor.y - new_h;
+ }
+
+ // Dragging TopMid: Fixed Anchor = Bottom edge (max.y)
+ HandleId::TopMid => {
+ let anchor_y = max.y;
+ let target_y = min.y + delta.y;
+ let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE);
+ min.y = anchor_y - new_h;
+ }
+
+ // Dragging TopRight: Fixed Anchor = BottomLeft (min.x, max.y)
+ HandleId::TopRight => {
+ let anchor_x = min.x;
+ let anchor_y = max.y;
+ let target_x = max.x + delta.x;
+ let target_y = min.y + delta.y;
+ let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE);
+ let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE);
+ max.x = anchor_x + new_w;
+ min.y = anchor_y - new_h;
+ }
+
+ // Dragging RightMid: Fixed Anchor = Left edge (min.x)
+ HandleId::RightMid => {
+ let anchor_x = min.x;
+ let target_x = max.x + delta.x;
+ let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE);
+ max.x = anchor_x + new_w;
+ }
+
+ // Dragging BottomRight: Fixed Anchor = TopLeft (min.x, min.y)
+ HandleId::BottomRight => {
+ let anchor = min;
+ let target = max + delta;
+ let new_w = (target.x - anchor.x).max(MIN_RECT_SIZE);
+ let new_h = (target.y - anchor.y).max(MIN_RECT_SIZE);
+ max.x = anchor.x + new_w;
+ max.y = anchor.y + new_h;
+ }
+
+ // Dragging BottomMid: Fixed Anchor = Top edge (min.y)
+ HandleId::BottomMid => {
+ let anchor_y = min.y;
+ let target_y = max.y + delta.y;
+ let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE);
+ max.y = anchor_y + new_h;
+ }
+
+ // Dragging BottomLeft: Fixed Anchor = TopRight (max.x, min.y)
+ HandleId::BottomLeft => {
+ let anchor_x = max.x;
+ let anchor_y = min.y;
+ let target_x = min.x + delta.x;
+ let target_y = max.y + delta.y;
+ let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE);
+ let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE);
+ min.x = anchor_x - new_w;
+ max.y = anchor_y + new_h;
+ }
+
+ // Dragging LeftMid: Fixed Anchor = Right edge (max.x)
+ HandleId::LeftMid => {
+ let anchor_x = max.x;
+ let target_x = min.x + delta.x;
+ let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE);
+ min.x = anchor_x - new_w;
+ }
+ }
+
+ Rect::from_min_max(min, max)
+}
+
+/// **Anchor-Based Proportional Resize System**
+///
+/// Recalculates the screen bounding box by locking the diagonally opposite point as
+/// a fixed anchor while preserving the aspect ratio.
+///
+/// For corner handles, uses the axis with the larger relative change to scale both
+/// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`.
+/// For mid-edge handles, delegates to `apply_resize`.
+pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
+ if !delta.x.is_finite() || !delta.y.is_finite() {
+ return image_rect;
+ }
+ let w0 = image_rect.width().max(1.0);
+ let h0 = image_rect.height().max(1.0);
+
+ match handle {
+ HandleId::TopLeft => {
+ let horizontal = -delta.x / w0;
+ let vertical = -delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.max.y - h), image_rect.max)
+ }
+ HandleId::TopRight => {
+ let horizontal = delta.x / w0;
+ let vertical = -delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(Pos2::new(image_rect.min.x, image_rect.max.y - h), Pos2::new(image_rect.min.x + w, image_rect.max.y))
+ }
+ HandleId::BottomLeft => {
+ let horizontal = -delta.x / w0;
+ let vertical = delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.min.y), Pos2::new(image_rect.max.x, image_rect.min.y + h))
+ }
+ HandleId::BottomRight => {
+ let horizontal = delta.x / w0;
+ let vertical = delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(image_rect.min, Pos2::new(image_rect.min.x + w, image_rect.min.y + h))
+ }
+ mid => apply_resize(image_rect, mid, delta),
+ }
+}
+
+/// **Non-Destructive Crop System**
+///
+/// In Crop Mode:
+/// 1. The handles adjust the normalized texture coordinates `uv_rect` ([0.0..1.0]).
+/// 2. The screen `image_rect` shrinks/expands proportionally in lockstep.
+/// 3. The pixel scale (`screen_pixels / uv_span`) remains invariant, ensuring that
+/// the visible image content NEVER stretches, jumps, or distorts.
+/// 4. The diagonally opposite point/edge is strictly locked as the fixed anchor.
+///
+/// Returns: `(new_image_rect, new_uv_rect)`
+pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: HandleId, delta: Vec2) -> (Rect, Rect) {
+ if !delta.x.is_finite() || !delta.y.is_finite() {
+ return (image_rect, uv_rect);
+ }
+ let w_screen = image_rect.width();
+ let h_screen = image_rect.height();
+ let w_uv = uv_rect.width();
+ let h_uv = uv_rect.height();
+
+ // Guard against degraded or non-finite zero rects
+ if w_screen <= 0.0
+ || h_screen <= 0.0
+ || w_uv <= 0.0
+ || h_uv <= 0.0
+ || !w_screen.is_finite()
+ || !h_screen.is_finite()
+ || !w_uv.is_finite()
+ || !h_uv.is_finite()
+ {
+ return (image_rect, uv_rect);
+ }
+
+ // Invariant ratios: screen pixels per unit UV and UV per screen pixel
+ let px_per_uv_x = w_screen / w_uv;
+ let px_per_uv_y = h_screen / h_uv;
+ let uv_per_px_x = w_uv / w_screen;
+ let uv_per_px_y = h_uv / h_screen;
+
+ // Minimum UV span corresponding to MIN_RECT_SIZE pixels
+ let min_uv_w = (MIN_RECT_SIZE * uv_per_px_x).min(w_uv * 0.95);
+ let min_uv_h = (MIN_RECT_SIZE * uv_per_px_y).min(h_uv * 0.95);
+
+ let mut new_screen = image_rect;
+ let mut new_uv = uv_rect;
+
+ // Identify which geometric edges are affected by this handle
+ let affects_left = matches!(handle, HandleId::TopLeft | HandleId::LeftMid | HandleId::BottomLeft);
+ let affects_right = matches!(handle, HandleId::TopRight | HandleId::RightMid | HandleId::BottomRight);
+ let affects_top = matches!(handle, HandleId::TopLeft | HandleId::TopMid | HandleId::TopRight);
+ let affects_bottom = matches!(handle, HandleId::BottomLeft | HandleId::BottomMid | HandleId::BottomRight);
+
+ // ------------------------------------------------------------------------
+ // HORIZONTAL (X) CROP CALCULATION
+ // ------------------------------------------------------------------------
+ if affects_left {
+ // Dragging Left: Fixed Anchor is the Right edge
+ let screen_anchor_x = image_rect.max.x;
+ let uv_anchor_u = uv_rect.max.x;
+
+ // Apply pointer delta in UV space
+ let target_u_min = uv_rect.min.x + delta.x * uv_per_px_x;
+
+ // Clamp: Cannot uncrop past 0.0 (texture edge), nor exceed anchor - min_uv_w
+ let clamped_u_min = target_u_min.clamp(0.0, (uv_anchor_u - min_uv_w).max(0.0));
+
+ // Recalculate screen bounding box from anchor using invariant scale
+ let new_uv_w = uv_anchor_u - clamped_u_min;
+ let new_screen_w = new_uv_w * px_per_uv_x;
+
+ new_uv.min.x = clamped_u_min;
+ new_screen.min.x = screen_anchor_x - new_screen_w;
+ new_screen.max.x = screen_anchor_x; // Explicitly locked
+ } else if affects_right {
+ // Dragging Right: Fixed Anchor is the Left edge
+ let screen_anchor_x = image_rect.min.x;
+ let uv_anchor_u = uv_rect.min.x;
+
+ let target_u_max = uv_rect.max.x + delta.x * uv_per_px_x;
+
+ // Clamp: Cannot crop narrower than anchor + min_uv_w, nor exceed 1.0 (texture edge)
+ let clamped_u_max = target_u_max.clamp((uv_anchor_u + min_uv_w).min(1.0), 1.0);
+
+ let new_uv_w = clamped_u_max - uv_anchor_u;
+ let new_screen_w = new_uv_w * px_per_uv_x;
+
+ new_uv.max.x = clamped_u_max;
+ new_screen.min.x = screen_anchor_x; // Explicitly locked
+ new_screen.max.x = screen_anchor_x + new_screen_w;
+ }
+
+ // ------------------------------------------------------------------------
+ // VERTICAL (Y) CROP CALCULATION
+ // ------------------------------------------------------------------------
+ if affects_top {
+ // Dragging Top: Fixed Anchor is the Bottom edge
+ let screen_anchor_y = image_rect.max.y;
+ let uv_anchor_v = uv_rect.max.y;
+
+ let target_v_min = uv_rect.min.y + delta.y * uv_per_px_y;
+
+ // Clamp: Cannot uncrop past 0.0, nor exceed anchor - min_uv_h
+ let clamped_v_min = target_v_min.clamp(0.0, (uv_anchor_v - min_uv_h).max(0.0));
+
+ let new_uv_h = uv_anchor_v - clamped_v_min;
+ let new_screen_h = new_uv_h * px_per_uv_y;
+
+ new_uv.min.y = clamped_v_min;
+ new_screen.min.y = screen_anchor_y - new_screen_h;
+ new_screen.max.y = screen_anchor_y; // Explicitly locked
+ } else if affects_bottom {
+ // Dragging Bottom: Fixed Anchor is the Top edge
+ let screen_anchor_y = image_rect.min.y;
+ let uv_anchor_v = uv_rect.min.y;
+
+ let target_v_max = uv_rect.max.y + delta.y * uv_per_px_y;
+
+ // Clamp: Cannot crop smaller than anchor + min_uv_h, nor exceed 1.0
+ let clamped_v_max = target_v_max.clamp((uv_anchor_v + min_uv_h).min(1.0), 1.0);
+
+ let new_uv_h = clamped_v_max - uv_anchor_v;
+ let new_screen_h = new_uv_h * px_per_uv_y;
+
+ new_uv.max.y = clamped_v_max;
+ new_screen.min.y = screen_anchor_y; // Explicitly locked
+ new_screen.max.y = screen_anchor_y + new_screen_h;
+ }
+
+ (new_screen, new_uv)
+}
+
+/// Resets the crop boundary back to [0.0..1.0] and expands `image_rect`
+/// to reveal the full image at the current screen pixel scale without shifting existing pixels.
+pub fn reset_crop(image_rect: Rect, uv_rect: Rect) -> (Rect, Rect) {
+ if uv_rect.width() <= 0.0 || uv_rect.height() <= 0.0 || !uv_rect.width().is_finite() || !uv_rect.height().is_finite() {
+ return (image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
+ }
+
+ let px_per_uv_x = image_rect.width() / uv_rect.width();
+ let px_per_uv_y = image_rect.height() / uv_rect.height();
+
+ let full_min_x = image_rect.min.x - uv_rect.min.x * px_per_uv_x;
+ let full_min_y = image_rect.min.y - uv_rect.min.y * px_per_uv_y;
+ let full_w = px_per_uv_x;
+ let full_h = px_per_uv_y;
+
+ let new_rect = Rect::from_min_size(pos2(full_min_x, full_min_y), vec2(full_w, full_h));
+ let new_uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+ (new_rect, new_uv)
+}
+
+// ============================================================================
+// 3. VISUAL RENDERING HELPERS
+// ============================================================================
+
+/// Vibrant amber/orange color for Crop Mode.
+pub const CROP_ACCENT: Color32 = Color32::from_rgb(230, 126, 34);
+
+/// Crisp blue color for Resize Mode.
+pub const RESIZE_ACCENT: Color32 = Color32::from_rgb(52, 152, 219);
+
+/// Renders the Rule-of-Thirds composition grid inside the given crop rectangle.
+pub fn draw_rule_of_thirds(painter: &egui::Painter, rect: Rect, stroke: Stroke) {
+ let w = rect.width();
+ let h = rect.height();
+ if w <= 0.0 || h <= 0.0 {
+ return;
+ }
+
+ let x1 = rect.min.x + w / 3.0;
+ let x2 = rect.min.x + (2.0 * w) / 3.0;
+ painter.line_segment([pos2(x1, rect.min.y), pos2(x1, rect.max.y)], stroke);
+ painter.line_segment([pos2(x2, rect.min.y), pos2(x2, rect.max.y)], stroke);
+
+ let y1 = rect.min.y + h / 3.0;
+ let y2 = rect.min.y + (2.0 * h) / 3.0;
+ painter.line_segment([pos2(rect.min.x, y1), pos2(rect.max.x, y1)], stroke);
+ painter.line_segment([pos2(rect.min.x, y2), pos2(rect.max.x, y2)], stroke);
+}
+
+/// Renders a circular handle with shadow, fill, and accent stroke matching the sandbox.
+pub fn draw_handle(painter: &egui::Painter, center: Pos2, is_active: bool, is_hovered: bool, crop_mode: bool, accent_color: Color32) {
+ let base_radius = 6.5;
+ let circle_radius = if is_active {
+ base_radius + 2.0
+ } else if is_hovered {
+ base_radius + 1.2
+ } else {
+ base_radius
+ };
+
+ let fill_color = if crop_mode {
+ if is_active || is_hovered { Color32::from_rgb(255, 230, 150) } else { Color32::from_rgb(255, 255, 255) }
+ } else if is_active || is_hovered {
+ Color32::from_rgb(210, 235, 255)
+ } else {
+ Color32::from_rgb(255, 255, 255)
+ };
+
+ // Draw handle shadow/glow
+ painter.circle_filled(center, circle_radius + 1.5, Color32::from_rgba_unmultiplied(0, 0, 0, 80));
+
+ // Draw handle circle
+ painter.circle_filled(center, circle_radius, fill_color);
+ painter.circle_stroke(center, circle_radius, Stroke::new(1.8, accent_color));
+}
+
+// ============================================================================
+// 4. UNIT TESTS: MATHEMATICAL VERIFICATION
+// ============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_resize_bottom_right_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, HandleId::BottomRight, vec2(50.0, 25.0));
+ assert_eq!(res.min, pos2(100.0, 100.0), "TopLeft anchor must remain fixed");
+ assert_eq!(res.max, pos2(350.0, 225.0), "BottomRight must expand by delta");
+ }
+
+ #[test]
+ fn test_resize_top_left_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, HandleId::TopLeft, vec2(20.0, 15.0));
+ assert_eq!(res.max, pos2(300.0, 200.0), "BottomRight anchor must remain fixed");
+ assert_eq!(res.min, pos2(120.0, 115.0), "TopLeft must move by delta");
+ }
+
+ #[test]
+ fn test_resize_clamping_prevents_inversion() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0));
+ let res = apply_resize(r, HandleId::TopLeft, vec2(500.0, 500.0));
+ assert_eq!(res.max, pos2(200.0, 200.0));
+ assert_eq!(res.min, pos2(200.0 - MIN_RECT_SIZE, 200.0 - MIN_RECT_SIZE));
+ assert!(res.width() >= MIN_RECT_SIZE);
+ assert!(res.height() >= MIN_RECT_SIZE);
+ }
+
+ #[test]
+ fn test_resize_top_right_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, HandleId::TopRight, vec2(10.0, -10.0));
+ assert_eq!(res.min.x, 100.0, "min.x (BL) anchor fixed");
+ assert_eq!(res.max.y, 200.0, "max.y (BL) anchor fixed");
+ assert_eq!(res.max.x, 310.0);
+ assert_eq!(res.min.y, 90.0);
+ }
+
+ #[test]
+ fn test_resize_bottom_left_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, HandleId::BottomLeft, vec2(-15.0, 20.0));
+ assert_eq!(res.max.x, 300.0, "max.x (TR) anchor fixed");
+ assert_eq!(res.min.y, 100.0, "min.y (TR) anchor fixed");
+ assert_eq!(res.min.x, 85.0);
+ assert_eq!(res.max.y, 220.0);
+ }
+
+ #[test]
+ fn test_resize_mid_edges() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+
+ let tm = apply_resize(r, HandleId::TopMid, vec2(50.0, -30.0));
+ assert_eq!(tm.min.x, 100.0, "X min unaffected");
+ assert_eq!(tm.max.x, 300.0, "X max unaffected");
+ assert_eq!(tm.max.y, 200.0, "Y max anchor locked");
+ assert_eq!(tm.min.y, 70.0);
+
+ let rm = apply_resize(r, HandleId::RightMid, vec2(40.0, 50.0));
+ assert_eq!(rm.min.x, 100.0, "X min anchor locked");
+ assert_eq!(rm.max.x, 340.0);
+ assert_eq!(rm.min.y, 100.0, "Y unaffected");
+ assert_eq!(rm.max.y, 200.0, "Y unaffected");
+ }
+
+ #[test]
+ fn test_crop_lockstep_ratio_preservation() {
+ let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+
+ let initial_px_per_uv_x = screen.width() / uv.width();
+ let initial_px_per_uv_y = screen.height() / uv.height();
+
+ let (new_screen, new_uv) = apply_crop(screen, uv, HandleId::RightMid, vec2(-80.0, 0.0));
+
+ assert_eq!(new_screen.min.x, 100.0, "Left edge anchored");
+ assert_eq!(new_screen.max.x, 420.0, "Right edge moved in by 80px");
+ assert_eq!(new_screen.width(), 320.0);
+
+ assert_eq!(new_uv.min.x, 0.0);
+ assert!((new_uv.max.x - 0.8).abs() < 1e-4);
+
+ let new_px_per_uv_x = new_screen.width() / new_uv.width();
+ assert!((new_px_per_uv_x - initial_px_per_uv_x).abs() < 1e-4, "Pixel scale must remain invariant in crop mode");
+
+ let new_px_per_uv_y = new_screen.height() / new_uv.height();
+ assert!((new_px_per_uv_y - initial_px_per_uv_y).abs() < 1e-4, "Pixel scale Y must remain invariant");
+ }
+
+ #[test]
+ fn test_crop_left_edge_anchoring() {
+ let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+
+ let (new_screen, new_uv) = apply_crop(screen, uv, HandleId::LeftMid, vec2(50.0, 0.0));
+
+ assert_eq!(new_screen.max.x, 500.0, "Right edge must remain strictly anchored");
+ assert_eq!(new_screen.min.x, 150.0, "Left edge must move by 50px");
+ assert_eq!(new_screen.width(), 350.0);
+
+ assert!((new_uv.min.x - 0.125).abs() < 1e-4);
+ assert_eq!(new_uv.max.x, 1.0);
+ }
+
+ #[test]
+ fn test_crop_uncrop_clamped_at_zero_and_one() {
+ let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+
+ let (new_screen, new_uv) = apply_crop(screen, uv, HandleId::LeftMid, vec2(-100.0, 0.0));
+ assert_eq!(new_uv.min.x, 0.0, "UV min cannot go below 0.0");
+ assert_eq!(new_screen.min.x, 100.0, "Screen min clamped at initial position");
+
+ let (new_screen2, new_uv2) = apply_crop(screen, uv, HandleId::RightMid, vec2(100.0, 0.0));
+ assert_eq!(new_uv2.max.x, 1.0, "UV max cannot exceed 1.0");
+ assert_eq!(new_screen2.max.x, 500.0, "Screen max clamped at initial position");
+ }
+
+ #[test]
+ fn test_reset_crop() {
+ let screen = Rect::from_min_max(pos2(150.0, 100.0), pos2(450.0, 350.0)); // 300 x 250
+ let uv = Rect::from_min_max(pos2(0.25, 0.0), pos2(0.75, 1.0)); // 0.5 x 1.0
+
+ let (full_rect, full_uv) = reset_crop(screen, uv);
+ assert_eq!(full_uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
+ assert_eq!(full_rect.width(), 600.0);
+ assert_eq!(full_rect.height(), 250.0);
+ assert_eq!(full_rect.min.x, 0.0);
+ }
+
+ #[test]
+ fn test_apply_resize_proportional_horizontal_only_scales_both_dimensions() {
+ let rect = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); // 100 x 100
+ // TopRight corner moved horizontally by -25px (from x=200 to x=175)
+ let resized = apply_resize_proportional(rect, HandleId::TopRight, vec2(-25.0, 0.0));
+ assert_eq!(resized.min.x, 100.0, "Left anchor preserved");
+ assert_eq!(resized.max.y, 200.0, "Bottom anchor preserved");
+ assert_eq!(resized.width(), 75.0, "Width scaled to 75%");
+ assert_eq!(resized.height(), 75.0, "Height scaled to 75% proportionally");
+ assert_eq!(resized.max.x, 175.0);
+ assert_eq!(resized.min.y, 125.0);
+ }
+
+ #[test]
+ fn test_apply_resize_proportional_bottom_right_anchor_locked() {
+ let rect = Rect::from_min_max(pos2(50.0, 50.0), pos2(150.0, 250.0)); // 100 x 200
+ // BottomRight moved down/right by (20px, 40px)
+ let resized = apply_resize_proportional(rect, HandleId::BottomRight, vec2(20.0, 40.0));
+ assert_eq!(resized.min, pos2(50.0, 50.0), "TopLeft anchor strictly locked");
+ assert_eq!(resized.width(), 120.0);
+ assert_eq!(resized.height(), 240.0);
+ }
+}
diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs
index 3030bfb293..9e117395cd 100644
--- a/crates/ui-egui/src/image_pick.rs
+++ b/crates/ui-egui/src/image_pick.rs
@@ -43,8 +43,16 @@ pub(crate) fn page_input(
let (vx, vy) = xf.screen_to_view(p);
let u = info_page.view_to_user(vx, vy);
let at = [f64::from(u[0]), f64::from(u[1])];
- view.picked_image =
- doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect }));
+ view.picked_image = doc.page_image_at(page, at).and_then(|index| {
+ doc.page_images(page).get(index).and_then(|i| {
+ let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page);
+ if screen_rect.is_positive() && screen_rect.contains(p) {
+ Some(PickedImage { page, index, rect: crate::canvas::image_selectable_bounds(i) })
+ } else {
+ None
+ }
+ })
+ });
}
/// The selected picture's highlight on page `page`.
@@ -159,7 +167,8 @@ impl PdfCraftApp {
return;
}
// The document may have changed since the click: the same picture must still be there.
- let current = self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(|im| im.rect));
+ let current =
+ self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(crate::canvas::image_selectable_bounds));
if current == Some(picked.rect) {
self.copy_page_image_file(picked.page, picked.index);
} else {
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 1050dbda3d..1bffb17b65 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -35,7 +35,9 @@ mod appearance;
pub mod canvas;
mod chrome;
mod combine_grid;
+pub mod image_engine;
mod image_pick;
+pub use image_engine::{HandleId, MIN_RECT_SIZE, apply_crop, apply_resize, apply_resize_proportional, reset_crop};
mod tags_ui;
pub use combine_grid::ThumbState as CombineThumb;
mod combine_ui;
@@ -68,7 +70,7 @@ mod zoom_snap;
pub mod marks {
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
}
-mod content_ui;
+pub mod content_ui;
mod link_ui;
mod object_ui;
pub use create_ui::Clip;
@@ -2923,14 +2925,13 @@ impl PdfCraftApp {
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 !self.copy_selected_text(i, ctx)
- && 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 copy
+ && !self.copy_selected_text(i, ctx)
+ && 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())
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 23871e68e3..d4d6545a9e 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -160,7 +160,7 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, is_pinned: bool) -> (e
t.text_faint
};
icons::paint(ui, pin_rect.shrink(3.0), "pin", 15.0, pin_tint);
- let tip = if is_pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
+ let tip = if is_pinned { "Unpin from Home" } else { "Pin to Home" };
pin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
if theme::hand(pin_resp).on_hover_text(tip).clicked() {
pin_clicked = true;
@@ -422,7 +422,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
tl!("Keep source fonts").to_string()
};
- crate::widgets::section_title(ui, tl!("FORMAT TEXT"));
+ crate::widgets::section_title(ui, "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| {
@@ -432,7 +432,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
if !font_choices.is_empty() {
ui.separator();
- ui.label(egui::RichText::new(tl!("Document fonts")).weak().small());
+ ui.label(egui::RichText::new("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() {
@@ -442,7 +442,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
}
ui.separator();
- ui.label(egui::RichText::new(tl!("Standard fonts")).weak().small());
+ ui.label(egui::RichText::new("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() {
@@ -454,10 +454,10 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
#[cfg(not(target_arch = "wasm32"))]
{
ui.separator();
- ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small());
+ ui.label(egui::RichText::new("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());
+ ui.label(egui::RichText::new("No installed fonts found.").weak().small());
} else {
for font in &installed {
let is_current =
@@ -479,11 +479,11 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
}
} else {
- ui.label(egui::RichText::new(tl!("Finding installed fonts…")).weak().small());
+ ui.label(egui::RichText::new("Finding installed fonts…").weak().small());
}
}
ui.separator();
- if ui.button(tl!("Load font from file…")).clicked() {
+ if ui.button("Load font from file…").clicked() {
app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false);
}
},
@@ -503,19 +503,19 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
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() {
+ if color_resp.on_hover_text("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() {
+ if crate::icons::button(ui, "list", 24.0, is_bullet, "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() {
+ if crate::icons::button(ui, "list-ordered", 24.0, is_num, "Numbered list").clicked() {
changed |= crate::edit_text_ui::toggle_numbered_list(&mut ed.text);
}
ui.add_space(2.0);
@@ -554,11 +554,11 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
ed.look.italic = !ed.look.italic;
changed = true;
}
- let sup_resp = ui.selectable_label(false, egui::RichText::new("T¹").size(13.0)).on_hover_text(tl!("Superscript"));
+ let sup_resp = ui.selectable_label(false, egui::RichText::new("T¹").size(13.0)).on_hover_text("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"));
+ let sub_resp = ui.selectable_label(false, egui::RichText::new("T₁").size(13.0)).on_hover_text("Subscript");
if sub_resp.clicked() {
changed |= crate::edit_text_ui::toggle_subscript(&mut ed);
}
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index b48adf301a..be305682ae 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -4989,3 +4989,436 @@ fn stale_clipboard_plain_text_does_not_paste_image() {
assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image");
}
+#[test]
+fn rotated_image_selection_and_crop() {
+ 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 id = h.state().views[0].id;
+ let r = h.state().views[0].page_screen_rect(0).expect("on screen");
+ let c = r.center();
+
+ // 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(), "selected");
+
+ // Right-click and rotate clockwise
+ 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("Rotate Clockwise").click();
+ h.run_steps(3);
+
+ let doc = h.state().session.get(id).unwrap();
+ let im_rotated = &doc.page_images(0)[0];
+ assert!(im_rotated.matrix[1].abs() > 1.0 || im_rotated.matrix[2].abs() > 1.0, "matrix is rotated: {:?}", im_rotated.matrix);
+
+ // Enter crop mode with normalized bounds [0.2, 0.2, 0.8, 0.8]
+ h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new([0.2, 0.2, 0.8, 0.8]));
+ h.run_steps(2);
+
+ // Apply crop via Enter key
+ h.key_press(egui::Key::Enter);
+ h.run_steps(4);
+
+ let doc = h.state().session.get(id).unwrap();
+ assert_eq!(doc.can_undo(), Some("Crop image"));
+ let im_cropped = &doc.page_images(0)[0];
+ assert!(im_cropped.matrix[1].abs() > 1.0 || im_cropped.matrix[2].abs() > 1.0, "matrix remains rotated: {:?}", im_cropped.matrix);
+ assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none()), "crop mode ended");
+}
+
+#[test]
+fn test_image_rotated_45_deg_obb_and_proportional_scale() {
+ use pdfcraft_ui_egui::ImageHandle;
+ use pdfcraft_ui_egui::canvas::ImageObb;
+ use std::f32::consts::FRAC_PI_4;
+
+ let center = egui::pos2(200.0, 200.0);
+ let width = 100.0_f32;
+ let height = 50.0_f32;
+ let theta = FRAC_PI_4;
+
+ let obb = ImageObb::from_center_size_angle(center, width, height, theta);
+
+ // 1. Verify it outputs an OBB (not an AABB):
+ assert!(obb.is_rotated);
+ let corners = obb.corners();
+ assert!((corners[0].x - corners[1].x).abs() > 1.0);
+ assert!((corners[0].y - corners[1].y).abs() > 1.0);
+ assert!((corners[1].x - corners[2].x).abs() > 1.0);
+ assert!((corners[1].y - corners[2].y).abs() > 1.0);
+
+ let edge_u = (corners[1] - corners[0]).length();
+ let edge_v = (corners[3] - corners[0]).length();
+ assert!((edge_u - width).abs() < 1e-4, "edge_u={edge_u}, width={width}");
+ assert!((edge_v - height).abs() < 1e-4, "edge_v={edge_v}, height={height}");
+
+ let dot = (corners[1] - corners[0]).dot(corners[3] - corners[0]);
+ assert!(dot.abs() < 1e-4, "adjacent edges must be perpendicular for OBB");
+
+ let handles = obb.handles();
+ assert_eq!(handles.len(), 8);
+
+ // 2. Drag TopRight handle with a pointer delta along local axes:
+ let delta = egui::vec2(10.0 * theta.cos(), 10.0 * theta.sin());
+ let anchor_before = obb.s00;
+
+ let scaled_obb = obb.scale_proportional(ImageHandle::TopRight, delta, 5.0);
+
+ // The diagonally opposite corner (s00) MUST remain locked as the fixed anchor point:
+ assert!((scaled_obb.s00.x - anchor_before.x).abs() < 1e-4);
+ assert!((scaled_obb.s00.y - anchor_before.y).abs() < 1e-4);
+
+ // Dimensions scale proportionally along local axes (aspect ratio preserved):
+ let new_aspect = scaled_obb.width / scaled_obb.height;
+ let old_aspect = width / height;
+ assert!((new_aspect - old_aspect).abs() < 1e-4, "aspect ratio preserved: {new_aspect} vs {old_aspect}");
+ assert!(scaled_obb.width > width, "width increased");
+ assert!(scaled_obb.height > height, "height increased proportionally");
+
+ // Rotation angle is preserved without skewing:
+ assert!((scaled_obb.theta - theta).abs() < 1e-4);
+}
+
+trait RectExt {
+ fn width(&self) -> f64;
+}
+
+impl RectExt for [f64; 4] {
+ fn width(&self) -> f64 {
+ (self[2] - self[0]).abs()
+ }
+}
+
+impl RectExt for egui::Rect {
+ fn width(&self) -> f64 {
+ f64::from(self.width())
+ }
+}
+
+#[test]
+fn test_image_8_handles_render_and_drag() {
+ 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 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(), "selected");
+
+ let view = &h.state().views[0];
+ let sel = view.image_selection.as_ref().unwrap();
+ let doc = h.state().session.get(view.id).unwrap();
+ let img = &doc.page_images(sel.page)[sel.index];
+ let xf =
+ pdfcraft_ui_egui::canvas::PageXform { rect: r, rot: view.rotation, pw: doc.info.pages[sel.page].width, ph: doc.info.pages[sel.page].height };
+ let obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(img, &xf, &doc.info, sel.page);
+
+ // Assert that exactly 8 unique resize handles (4 corners, 4 midpoints) are generated
+ let handles = obb.handles();
+ assert_eq!(handles.len(), 8, "exactly 8 resize handles generated");
+
+ let corners = [
+ pdfcraft_ui_egui::ImageHandle::TopLeft,
+ pdfcraft_ui_egui::ImageHandle::TopRight,
+ pdfcraft_ui_egui::ImageHandle::BottomRight,
+ pdfcraft_ui_egui::ImageHandle::BottomLeft,
+ ];
+ let midpoints = [
+ pdfcraft_ui_egui::ImageHandle::TopCenter,
+ pdfcraft_ui_egui::ImageHandle::RightCenter,
+ pdfcraft_ui_egui::ImageHandle::BottomCenter,
+ pdfcraft_ui_egui::ImageHandle::LeftCenter,
+ ];
+ for corner in &corners {
+ assert!(handles.iter().any(|(h, _)| h == corner), "missing corner handle: {:?}", corner);
+ }
+ for midpoint in &midpoints {
+ assert!(handles.iter().any(|(h, _)| h == midpoint), "missing midpoint handle: {:?}", midpoint);
+ }
+
+ for i in 0..8 {
+ for j in (i + 1)..8 {
+ assert!((handles[i].1 - handles[j].1).length() > 1.0, "handles {} and {} must be unique positions", i, j);
+ }
+ }
+
+ // Simulate a pointer drag on a corner that shrinks the image to 5x5 pixels and assert it does not panic
+ let tr_pos = obb.handle_pos(pdfcraft_ui_egui::ImageHandle::TopRight);
+ let anchor = obb.s00;
+ let shrink_target = anchor + egui::vec2(5.0, 5.0);
+ h.hover_at(tr_pos);
+ h.run_steps(1);
+ h.drag_at(tr_pos);
+ h.run_steps(1);
+ h.hover_at(shrink_target);
+ h.run_steps(2);
+ h.drop_at(shrink_target);
+ h.run_steps(4);
+}
+
+#[test]
+fn test_image_context_menu_precedence() {
+ 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 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(), "selected");
+
+ // Simulate a secondary click strictly inside an image's bounds
+ 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 that the image widget consumes the click and the canvas background menu does not trigger
+ h.get_by_label("Rotate Clockwise");
+ h.get_by_label("Flip horizontal");
+ assert!(h.query_all_by_label("Rotate view clockwise").next().is_none(), "canvas background menu must not trigger");
+ assert!(h.query_all_by_label("Add bookmark").next().is_none(), "canvas background menu must not trigger");
+ assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger");
+}
+
+#[test]
+fn test_crop_updates_rect_instantly() {
+ let mut png = Vec::new();
+ image::RgbImage::from_pixel(100, 50, 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
+ app
+ });
+ h.run_steps(4);
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(2);
+
+ // Add image via content_ui
+ h.state_mut().add_image("logo.png".into(), png);
+ h.run_steps(3);
+
+ let id = h.state().views[0].id;
+ let initial_img = match &h.state().session.get(id).unwrap().added()[0].content {
+ pdfcraft_engine::AddedContent::Image(img) => img.clone(),
+ _ => panic!("expected AddedContent::Image"),
+ };
+ let initial_w = initial_img.rect.width();
+
+ // Trigger a crop event that removes 50% of an image's width
+ let mut cropped = initial_img.clone();
+ pdfcraft_ui_egui::content_ui::update_image_crop(&mut cropped, [0.5, 0.0, 0.0, 0.0]);
+ let update_edit = pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(cropped) };
+ h.state_mut().apply_edit(update_edit);
+
+ // Assert that in the immediate next frame, the image.rect.width() is exactly 50% of the original, with no padding left over
+ h.run_steps(1);
+ let doc = h.state().session.get(id).unwrap();
+ let img = match &doc.added()[0].content {
+ pdfcraft_engine::AddedContent::Image(img) => img.clone(),
+ _ => panic!("expected AddedContent::Image"),
+ };
+ let w = img.rect.width();
+ let expected_w = initial_w * 0.5;
+ assert!((w - expected_w).abs() < 1e-3, "image.rect.width() must be exactly 50% ({expected_w}), got {w}");
+}
+
+#[test]
+fn crop_action_pills_interactive_and_apply_cancel_reset() {
+ 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
+ let doc_id = h.state().views[0].id;
+ let original_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
+ h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(original_rect));
+ h.state_mut().views[0].image_selection.as_mut().unwrap().crop_mode = true;
+ h.run_steps(2);
+
+ // Set smaller crop rect (50x50 in center)
+ 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);
+ h.run_steps(2);
+
+ let pill_y = smaller.max.y + 17.0;
+ let done_pos = egui::pos2(smaller.max.x - 20.0, pill_y);
+ h.hover_at(done_pos);
+ h.run_steps(1);
+ assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "Done pill has PointingHand cursor");
+
+ let cancel_pos = egui::pos2(smaller.max.x - 85.0, pill_y);
+ h.hover_at(cancel_pos);
+ h.run_steps(1);
+ assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "Cancel pill has PointingHand cursor");
+
+ // Click Cancel pill: exits crop mode without applying changes
+ click_at(&mut h, cancel_pos);
+ h.run_steps(3);
+ assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none() && !s.crop_mode), "Cancel exits crop mode");
+ assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(original_rect), "image is uncropped");
+
+ // Re-enter crop mode and set crop rect
+ h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(original_rect));
+ h.state_mut().views[0].image_selection.as_mut().unwrap().crop_mode = true;
+ h.state_mut().views[0].image_selection.as_mut().unwrap().crop_rect = Some(smaller);
+ h.run_steps(2);
+
+ // Click "Remove Crop" pill
+ let reset_pos = egui::pos2(smaller.max.x - 170.0, pill_y);
+ h.hover_at(reset_pos);
+ h.run_steps(1);
+ assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "Remove Crop pill has PointingHand cursor");
+ click_at(&mut h, reset_pos);
+ h.run_steps(2);
+ let current_keep = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep;
+ assert_eq!(current_keep, original_rect, "Remove Crop restored crop to full bounds");
+
+ // Re-set smaller crop and click "Done" pill to apply
+ h.state_mut().views[0].image_selection.as_mut().unwrap().crop_rect = Some(smaller);
+ h.run_steps(2);
+ let done_pos_after = egui::pos2(smaller.max.x - 20.0, pill_y);
+ click_at(&mut h, done_pos_after);
+ h.run_steps(4);
+
+ assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none() && !s.crop_mode), "Done exits crop mode");
+ let after_images = h.state().session.get(doc_id).unwrap().page_images(0);
+ assert_ne!(after_images[0].visible_rect, Some(original_rect), "crop was applied");
+}
+
+#[test]
+fn test_cropped_image_selection_and_bounding_box() {
+ 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);
+
+ let doc_id = h.state().views[0].id;
+ let full_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
+
+ // Apply crop to the center quarter: [0.25, 0.25, 0.75, 0.75]
+ let crop_keep = [
+ full_rect[0] + (full_rect[2] - full_rect[0]) * 0.25,
+ full_rect[1] + (full_rect[3] - full_rect[1]) * 0.25,
+ full_rect[0] + (full_rect[2] - full_rect[0]) * 0.75,
+ full_rect[1] + (full_rect[3] - full_rect[1]) * 0.75,
+ ];
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Crop(crop_keep) });
+ h.run_steps(4);
+
+ let img = &h.state().session.get(doc_id).unwrap().page_images(0)[0];
+ assert_eq!(img.rect, full_rect, "underlying CTM rect is non-destructively preserved");
+ let vis = img.visible_rect.expect("has visible_rect");
+ assert!((vis[0] - crop_keep[0]).abs() < 1e-3);
+ assert!((vis[1] - crop_keep[1]).abs() < 1e-3);
+ assert!((vis[2] - crop_keep[2]).abs() < 1e-3);
+ assert!((vis[3] - crop_keep[3]).abs() < 1e-3);
+
+ // Verify selectable bounds helper gives the cropped intersection
+ assert_eq!(pdfcraft_ui_egui::canvas::image_selectable_bounds(img), vis);
+
+ // Select tool (mouse tool)
+ h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
+ h.run_steps(2);
+
+ let page_rect = h.state().views[0].page_screen_rect(0).expect("on screen");
+ // Center point is inside the cropped area:
+ let center = page_rect.center();
+ click_at(&mut h, center);
+ h.run_steps(2);
+
+ let picked = h.state().views[0].picked_image.expect("image is picked when clicking inside crop");
+ assert_eq!(picked.page, 0);
+ assert_eq!(picked.index, 0);
+ // The selection bounding box wraps the visible cropped area, NOT the full CTM rect!
+ assert_eq!(picked.rect, vis, "picked rect wraps the cropped bounds");
+
+ // Deselect
+ h.key_press(egui::Key::Escape);
+ h.run_steps(2);
+ assert!(h.state().views[0].picked_image.is_none());
+
+ // Corner point (e.g. top-left corner of the page) is outside the crop box but inside full CTM rect
+ let corner = page_rect.min + egui::vec2(10.0, 10.0);
+ click_at(&mut h, corner);
+ h.run_steps(2);
+ assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image");
+}
diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs
index 48f12feab8..893f48bf49 100644
--- a/crates/ui-egui/tests/print.rs
+++ b/crates/ui-egui/tests/print.rs
@@ -244,14 +244,17 @@ fn print_dialog_shows_the_printer_driver_options() {
h.get_by_label("Printer properties");
h.get_by_label("Media");
h.get_by_label("Paper tray");
- assert_eq!(h.state().print_draft.job("form.pdf").options, [("InputSlot".to_string(), "Tray2".to_string())]);
+ assert_eq!(
+ h.state().print_draft.job("form.pdf").options,
+ [("InputSlot".to_string(), "Tray2".to_string()), ("media".to_string(), "Letter".to_string())]
+ );
h.get_by_label("Reset to printer defaults").click();
h.run_steps(2);
- assert!(h.state().print_draft.job("form.pdf").options.is_empty(), "defaults are not sent");
+ assert_eq!(h.state().print_draft.job("form.pdf").options, [("media".to_string(), "Letter".to_string())], "defaults are not sent");
// Options read for another printer never go with this one's job.
h.state_mut().print_draft.driver_choices.insert("InputSlot".into(), "Tray2".into());
h.state_mut().print_draft.printer = Some("Other_Queue".into());
- assert!(h.state().print_draft.job("form.pdf").options.is_empty());
+ assert_eq!(h.state().print_draft.job("form.pdf").options, [("media".to_string(), "Letter".to_string())]);
}
/// The desktop app lists printers in the background (PowerShell on Windows takes a second or
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index db0bdb9cd7..767da4bce7 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -1,10162 +1,10144 @@
-# PdfCraft parity checklist vs Acrobat Pro (offline feature set). Generated from plan docs; maintained by hand.
-# Validated and reported by `cargo xtask parity`. Schema: see the [schema] table.
-# Status rules: `shipped` = usable today with at least one cited test; `partial` = part exists (notes say what is
-# missing); `planned` = not started; `na` = Adobe-cloud-only (notes name the local or pluggable alternative).
-# `commands` lists registry ids from crates/engine/src/commands.rs only; `tools` lists crates/automation/src/tools.rs names.
-[schema]
-version = 1
-statuses = ["planned", "partial", "shipped", "na"]
-tiers = ["P0", "P1", "P2", "P3", "N/A"]
-areas = { A = "core", B = "view", C = "edit", D = "organize", E = "comment", F = "form", G = "protect", H = "sign", I = "ocr", J = "create", K = "optimize", L = "print", M = "a11y", N = "misc" }
-
-# ==================================================================================================
-# A. Core document model and fidelity
-# ==================================================================================================
-
-[[feature]]
-id = "core.word-count"
-title = "Word and character count"
-area = "A"
-tier = "P2"
-milestone = "M2"
-status = "shipped"
-commands = ["file.word_count"]
-tools = ["text_word_count"]
-evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"]
-notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md."
-
-[[feature]]
-id = "core.open-pdf"
-title = "Open PDF files"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.open"]
-tools = ["doc_open"]
-evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits"]
-notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
-
-[[feature]]
-id = "core.repair-broken-files"
-title = "Open damaged files by reconstructing the cross-reference table"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.open"]
-tools = ["doc_open"]
-evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow"]
-notes = ""
-
-[[feature]]
-id = "core.malformed-input-safety"
-title = "Reject garbage or hostile input without crashing"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully"]
-notes = "fuzzed nightly (M1.10)"
-
-[[feature]]
-id = "core.repair-log"
-title = "Record repairs and show them to the user"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = ["doc_info"]
-evidence = ["crates/ui-egui/tests/viewer.rs::damaged_files_say_they_were_repaired"]
-notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them"
-
-[[feature]]
-id = "core.xref-streams"
-title = "Read cross-reference streams and compressed object streams"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/cos/tests/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"]
-notes = ""
-
-[[feature]]
-id = "core.hybrid-xref"
-title = "Hybrid-reference files (/XRefStm) and quirky xref subsections"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = ["doc_open"]
-evidence = ["crates/cos/src/document.rs::hybrid_reference_files_read_the_xref_stream_too"]
-notes = "/XRefStm entries supplement the classic table and replace its free entries for objects in object streams; each section is merged as a unit so older revisions only fill gaps"
-
-[[feature]]
-id = "core.revision-chain"
-title = "Follow /Prev revision chains with loop protection"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_revisions"]
-evidence = ["crates/cos/src/document.rs::looping_revision_chains_stop", "crates/cos/src/document.rs::revision_ends_split_incremental_updates"]
-notes = "/Prev chains followed newest first with a visited set (loops stop and are logged); revisions listed and opened (doc_revisions, doc_open_revision)"
-
-[[feature]]
-id = "core.stream-filters"
-title = "Standard stream filters (Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors"]
-notes = ""
-
-[[feature]]
-id = "core.image-filters"
-title = "Image codecs in the filters crate (DCT, JPX, JBIG2, CCITT)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "partial"
-commands = []
-tools = []
-evidence = []
-notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2"
-
-[[feature]]
-id = "core.decompression-limits"
-title = "Bounded decompression (filter bomb protection)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/filters/src/flate.rs::bomb_is_bounded", "crates/filters/src/lib.rs::limit_applies_to_intermediate_buffers"]
-notes = ""
-
-[[feature]]
-id = "core.open-rc4"
-title = "Open RC4-encrypted documents (40/128-bit)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.open"]
-tools = ["doc_open"]
-evidence = ["crates/crypt/src/tests.rs::rc4_reference_vectors", "crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"]
-notes = ""
-
-[[feature]]
-id = "core.open-aes"
-title = "Open AES-128 and AES-256 encrypted documents (R4-R6)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.open"]
-tools = ["doc_open"]
-evidence = ["crates/crypt/src/tests.rs::aes_cbc_nist_sp800_38a_vectors", "crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"]
-notes = ""
-
-[[feature]]
-id = "core.password-prompt"
-title = "Ask for the user or owner password, with retry"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.open"]
-tools = ["doc_open"]
-evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt"]
-notes = ""
-
-[[feature]]
-id = "core.unicode-passwords"
-title = "Unicode passwords normalised with SASLprep"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/crypt/src/tests.rs::r6_passwords_are_saslprepped"]
-notes = ""
-
-[[feature]]
-id = "core.owner-password-unlock"
-title = "Owner password lifts document restrictions"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["protect.unlock"]
-tools = ["doc_open"]
-evidence = ["crates/engine/src/tests.rs::restricted_documents_refuse_changes_unless_opened_by_the_owner", "crates/engine/src/tests.rs::owner_password_of_older_revisions_opens_the_viewer_too", "crates/crypt/src/tests.rs::owner_authentication_recovers_the_user_password", "crates/engine/src/tests.rs::the_permissions_password_unlocks_an_open_document", "crates/ui-egui/tests/editing.rs::the_security_notice_unlocks_with_the_permissions_password", "crates/ui-egui/tests/editing.rs::remove_security_asks_for_the_permissions_password_first"]
-notes = "at open (the password prompt takes either password), or after opening a restricted file that needed no password (#995, #1077): Unlock… on the security notice and in Properties ▸ Security, and changing security asks for it first. Not yet: an automation tool to unlock an open document (reopen with doc_open and the password instead)"
-
-[[feature]]
-id = "core.permissions-enforced"
-title = "Honour document permissions (print, modify, assemble, copy)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"]
-notes = ""
-
-[[feature]]
-id = "core.crypt-filters"
-title = "Crypt filters (Identity, /StmF, /StrF)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone"]
-notes = ""
-
-[[feature]]
-id = "core.attachment-only-encryption"
-title = "Attachment-only encryption (/EFF) opens without a password"
-area = "A"
-tier = "P1"
-milestone = "M1"
-status = "partial"
-commands = []
-tools = []
-evidence = []
-notes = "two corpus files still ask for a password (M1.6)"
-
-[[feature]]
-id = "core.unencrypted-metadata"
-title = "Leave XMP metadata readable when /EncryptMetadata is false"
-area = "A"
-tier = "P1"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/cos/tests/encryption.rs::unencrypted_metadata_is_left_readable"]
-notes = ""
-
-[[feature]]
-id = "core.unsupported-handlers"
-title = "Clear message for unsupported security handlers"
-area = "A"
-tier = "P1"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/crypt/src/tests.rs::unsupported_handlers_are_reported"]
-notes = ""
-
-[[feature]]
-id = "core.open-public-key"
-title = "Open certificate-encrypted (public-key handler) documents"
-area = "A"
-tier = "P1"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.incremental-save"
-title = "Save as an incremental update (original bytes kept, atomic write)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.save"]
-tools = ["doc_save"]
-evidence = ["crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"]
-notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI)."
-
-[[feature]]
-id = "core.save-as-full-rewrite"
-title = "Save as a new file with a full rewrite and garbage collection"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.save_as"]
-tools = ["doc_save"]
-evidence = ["crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"]
-notes = ""
-
-[[feature]]
-id = "core.object-stream-compression"
-title = "Compress objects into object streams behind an xref stream on full save"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.save_as"]
-tools = ["doc_save"]
-evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/cos/tests/writer.rs::objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams"]
-notes = ""
-
-[[feature]]
-id = "core.encrypted-save"
-title = "Save encrypted documents re-encrypted under the same keys"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = ["file.save"]
-tools = ["doc_save"]
-evidence = ["crates/cos/tests/encryption.rs::incremental_edits_of_encrypted_documents_stay_encrypted", "crates/engine/src/tests.rs::encrypted_documents_open_edit_and_save_encrypted"]
-notes = ""
-
-[[feature]]
-id = "core.copy-on-write-fidelity"
-title = "Preserve untouched objects byte-for-byte (copy-on-write edits)"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/cos/src/document.rs::reads_classic_revision_and_edits_are_copy_on_write"]
-notes = ""
-
-[[feature]]
-id = "core.write-validity"
-title = "Written files pass qpdf --check"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/automation.rs::a_root_less_write_output_is_atomic_too"]
-notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle). Every PDF leaves the engine checked (it reopens with its pages): saves, new files from Combine/Extract/Split/Create, signed, timestamped and LTV-embedded files, OCR batch output; and every writer goes through one staging-file writer (the desktop app, the tools with or without --root, the CLI), so a failed write never leaves a truncated file (#1698, #2166)"
-
-[[feature]]
-id = "core.deterministic-save"
-title = "Deterministic full-save mode"
-area = "A"
-tier = "P2"
-milestone = "M1"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "M1.9 option"
-
-[[feature]]
-id = "core.linearization"
-title = "Linearized save (Fast Web View)"
-area = "A"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.revision-list"
-title = "List document revisions from the xref chain"
-area = "A"
-tier = "P1"
-milestone = "M1"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_revisions"]
-evidence = ["crates/cos/src/document.rs::revision_ends_split_incremental_updates", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "revision ends from the xref chain (linearized first-page sections fold into their revision); listed in Document Properties ▸ Advanced and by doc_revisions with the signatures covering each"
-
-[[feature]]
-id = "core.view-revision"
-title = "View an earlier revision as a read-only snapshot"
-area = "A"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_open_revision"]
-evidence = ["crates/ui-egui/tests/viewer.rs::an_earlier_revision_opens_from_document_properties", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "opens the revision's bytes as a new unsaved document named ' (revision n)'"
-
-[[feature]]
-id = "core.lazy-loading"
-title = "Lazy loading of very large (GB) files within a memory budget"
-area = "A"
-tier = "P0"
-milestone = "M1"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "ByteSource (M1.5); files are buffered whole today. Viewing holds the file once and editing twice: edits let go of the previous working file before writing the next (a 1 GiB file peaked at three copies after a few edits, now two). Saving still peaks at three. Reading from disk on demand waits on the renderer (hayro parses one contiguous buffer, gap 2)."
-
-[[feature]]
-id = "core.pdf20-utf8-strings"
-title = "PDF 2.0 UTF-8 text strings"
-area = "A"
-tier = "P1"
-milestone = "M2"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.pdf20-associated-files"
-title = "PDF 2.0 associated files (/AF)"
-area = "A"
-tier = "P1"
-milestone = "M2"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.pdf20-dpart"
-title = "PDF 2.0 document parts (DPart)"
-area = "A"
-tier = "P2"
-milestone = "M2"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.pdf20-namespaces"
-title = "PDF 2.0 structure namespaces"
-area = "A"
-tier = "P1"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.arlington-validation"
-title = "Structural validation against the Arlington PDF model"
-area = "A"
-tier = "P1"
-milestone = "M2"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.undo-redo"
-title = "Undo and redo every document edit"
-area = "A"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["edit.undo", "edit.redo"]
-tools = ["edit_undo", "edit_redo"]
-evidence = ["crates/engine/src/tests.rs::undo_and_redo_restore_exact_states", "crates/ui-egui/tests/editing.rs::organize_delete_then_undo_and_redo_with_keys"]
-notes = ""
-
-[[feature]]
-id = "core.undo-labels"
-title = "Edit menu names the step to undo or redo"
-area = "A"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["edit.undo", "edit.redo"]
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::edit_menu_names_the_step_to_undo", "crates/engine/src/commands.rs::enablement_follows_the_document_state"]
-notes = ""
-
-[[feature]]
-id = "core.undo-after-save"
-title = "Undo still works after saving"
-area = "A"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["edit.undo"]
-tools = ["edit_undo"]
-evidence = ["crates/engine/src/tests.rs::undo_still_works_after_save"]
-notes = ""
-
-[[feature]]
-id = "core.atomic-batch-edits"
-title = "Multi-step edits are one undo step and all-or-nothing"
-area = "A"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/engine/src/tests.rs::batch_is_one_undo_step_and_all_or_nothing", "crates/engine/src/tests.rs::failed_edit_changes_nothing"]
-notes = ""
-
-[[feature]]
-id = "core.history-panel"
-title = "History panel with named snapshots"
-area = "A"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.autosave"
-title = "Autosave changed documents periodically"
-area = "A"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/engine/src/tests.rs::autosave_snapshots_only_changed_documents", "crates/ui-egui/tests/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents"]
-notes = ""
-
-[[feature]]
-id = "core.crash-recovery"
-title = "Offer to recover unsaved work after a crash"
-area = "A"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"]
-notes = "A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before."
-
-[[feature]]
-id = "core.encrypted-autosave"
-title = "Autosaves of encrypted documents stay encrypted"
-area = "A"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/recovery.rs::encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password"]
-notes = ""
-
-[[feature]]
-id = "core.web-recovery"
-title = "Crash recovery in the web app"
-area = "A"
-tier = "P1"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "core.unsaved-changes-prompt"
-title = "Ask before closing or quitting with unsaved changes"
-area = "A"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["file.close"]
-tools = ["doc_close"]
-evidence = ["crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename"]
-notes = ""
-
-[[feature]]
-id = "core.dirty-indicator"
-title = "Unsaved-changes marker on document tabs"
-area = "A"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::organize_delete_then_undo_and_redo_with_keys", "crates/engine/src/tests.rs::edits_update_view_data_and_mark_dirty"]
-notes = ""
-
-[[feature]]
-id = "core.close-document"
-title = "Close a document"
-area = "A"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["file.close"]
-tools = ["doc_close"]
-evidence = ["crates/ui-egui/tests/editing.rs::clean_tabs_close_without_asking"]
-notes = ""
-
-[[feature]]
-id = "core.close-all"
-title = "Close all documents"
-area = "A"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["file.close_all"]
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::close_all_asks_only_for_changed_documents"]
-notes = "clean documents close; each changed one asks"
-
-[[feature]]
-id = "core.revert"
-title = "Revert to the last saved version"
-area = "A"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["file.revert"]
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::revert_after_confirming", "crates/engine/src/tests.rs::revert_goes_back_to_the_saved_version"]
-notes = "with confirmation; clears undo, as Acrobat"
-
-[[feature]]
-id = "core.reload-changed-files"
-title = "Reload a document when its file changes on disk"
-area = "A"
-tier = "P2"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one", "crates/ui-egui/tests/reload.rs::saving_over_a_file_changed_by_something_else_asks_first", "crates/ui-egui/tests/reload.rs::saving_checks_the_file_with_the_watcher_off_too", "crates/ui-egui/tests/reload.rs::opening_a_file_that_is_open_focuses_its_tab"]
-notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Save asks before writing over a version of the file the document did not come from, watcher on or off, with Save as… to keep it (#2166); a file that is open already comes to the front instead of opening twice. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert."
-
-[[feature]]
-id = "core.signed-save-policy"
-title = "Force incremental saves for signed documents"
-area = "A"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["file.save"]
-tools = ["doc_save"]
-evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"]
-notes = "saves of signed documents are always incremental: full rewrites are refused, and edits that would need one (applying redactions, changing security, sanitizing) are refused before they change anything"
-
-[[feature]]
-id = "core.drag-drop-open"
-title = "Open files by drag and drop"
-area = "A"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it"]
-notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously"
-
-[[feature]]
-id = "core.recent-files"
-title = "Local recent-files list"
-area = "A"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["file.open_recent", "file.clear_recent"]
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/ui.rs::home_removes_one_recent_file_or_clears_them_all"]
-notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it; Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)"
-
-[[feature]]
-id = "core.reopen-last-session"
-title = "Reopen the files from the last session at startup"
-area = "A"
-tier = "P2"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"]
-notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Not yet: zoom, layout and scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen."
-
-[[feature]]
-id = "core.usage-rights-preserve"
-title = "Preserve usage-rights (UR3) signatures from Reader-extended files"
-area = "A"
-tier = "P3"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "preserve only"
-
-# ==================================================================================================
-# B. Viewing and navigation
-# ==================================================================================================
-
-[[feature]]
-id = "view.render-pages"
-title = "Render pages (text, vectors, images)"
-area = "B"
-tier = "P0"
-milestone = "M2"
-status = "partial"
-commands = []
-tools = ["page_render"]
-evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/raster.rs::user_unit_scales_the_page_and_everything_on_it"]
-notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value"
-
-[[feature]]
-id = "view.render-shadings-patterns"
-title = "Shadings, gradients and tiling patterns"
-area = "B"
-tier = "P0"
-milestone = "M2"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::self_referencing_tiling_pattern_terminates"]
-notes = "via hayro bootstrap"
-
-[[feature]]
-id = "view.render-transparency"
-title = "Blend modes, soft masks and transparency groups"
-area = "B"
-tier = "P0"
-milestone = "M2"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"]
-notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones"
-
-[[feature]]
-id = "view.render-knockout"
-title = "Knockout transparency groups"
-area = "B"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = []
-tools = ["doc_save"]
-evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "full rewrites of signed documents are refused; saves stay incremental"
-
-[[feature]]
-id = "view.render-type3"
-title = "Type 3 fonts"
-area = "B"
-tier = "P0"
-milestone = "M2"
-status = "partial"
-commands = []
-tools = []
-evidence = []
-notes = "hayro bootstrap with vendored patches"
-
-[[feature]]
-id = "view.render-cid-fonts"
-title = "CID fonts and CMaps incl. CJK without ToUnicode"
-area = "B"
-tier = "P0"
-milestone = "M2"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana"]
-notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face)"
-
-[[feature]]
-id = "view.render-image-codecs"
-title = "JBIG2, JPEG 2000, CCITT and JPEG images"
-area = "B"
-tier = "P0"
-milestone = "M2"
-status = "partial"
-commands = []
-tools = []
-evidence = []
-notes = "hayro bootstrap"
-
-[[feature]]
-id = "view.render-annotation-appearances"
-title = "Annotation and widget appearances incl. appearance states"
-area = "B"
-tier = "P0"
-milestone = "M2"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source"]
-notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet"
-
-[[feature]]
-id = "view.render-error-isolation"
-title = "Per-page error isolation (a bad page never takes down the app)"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"]
-notes = ""
-
-[[feature]]
-id = "view.render-priority"
-title = "Render visible pages first, then neighbours and thumbnails"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::inline_pool_renders_in_priority_order"]
-notes = ""
-
-[[feature]]
-id = "view.deep-zoom-tiles"
-title = "Tiled rendering keeps deep zoom sharp"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"]
-notes = "capped at 8192 px per side (M3.3); tiles of a zoom already rendered stay cached while their page is on screen, so zooming away and back shows the area sharp instead of re-rendering it tile by tile (#2196)"
-
-[[feature]]
-id = "view.layout-continuous"
-title = "Single page continuous scrolling"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.continuous"]
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"]
-notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)"
-
-[[feature]]
-id = "view.layout-single-page"
-title = "Single page view"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.layout.single"]
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"]
-notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette"
-
-[[feature]]
-id = "view.layout-two-up"
-title = "Two-page view"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.layout.two_up"]
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/viewer.rs::two_page_view_steps_a_spread_at_a_time"]
-notes = "next/previous page step a spread at a time"
-
-[[feature]]
-id = "view.layout-two-up-scrolling"
-title = "Two-page scrolling"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"]
-notes = "the two-page view scrolls continuously"
-
-[[feature]]
-id = "view.cover-page"
-title = "Show cover page in two-page view"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["view.layout.cover"]
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/viewer.rs::page_down_steps_every_spread_when_several_fit_on_screen"]
-notes = "View ▸ Page display, the rail's Page display button or the palette; disabled outside two-page view; next / previous page step one spread at a time even when several spreads fit on screen"
-
-[[feature]]
-id = "view.page-gaps"
-title = "Show or hide gaps between pages"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.auto-scroll"
-title = "Automatic scrolling"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"]
-notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented."
-
-[[feature]]
-id = "view.zoom-in-out"
-title = "Zoom in and out around the cursor (keys, wheel, pinch)"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::zoom_keeps_the_point_under_the_cursor_still", "crates/ui-egui/tests/ui.rs::trackpad_pinch_zooms_the_pdf_under_the_pointer"]
-notes = "A trackpad pinch zooms the PDF beneath the pointer in either direction, keeping the same page point in place. The viewer handles the platform's zoom gesture where it is available."
-
-[[feature]]
-id = "view.zoom-to-percent"
-title = "Zoom to a chosen percentage"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"]
-notes = "PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display"
-
-[[feature]]
-id = "view.zoom-actual-size"
-title = "Actual size"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"]
-notes = "Actual size is 100% on PdfCraft's fixed 96-dpi basis: 96/72 logical pixels per PDF point. Acrobat's Actual Size uses its Page Display resolution (ppi / 72 logical pixels per PDF point at 100%), which PdfCraft does not offer yet, so the two can draw a page at different sizes. Tracked with misc.prefs-page-display"
-
-[[feature]]
-id = "view.zoom-fit-page"
-title = "Fit page"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.fit_one_page"]
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices", "crates/ui-egui/tests/view.rs::fit_page_holds_zoom_across_mixed_page_sizes", "crates/ui-egui/tests/view.rs::single_page_fit_page_fits_the_shown_page"]
-notes = "View ▸ Zoom to page level (⌘0) and the rail's Page display menu, which also offers Fit one full page (single-page view at Fit page). Scrolling views fit their largest page so mixed sizes never jump the zoom; single-page view fits the shown page"
-
-[[feature]]
-id = "view.zoom-fit-width"
-title = "Fit width"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.fit_width_scrolling"]
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices"]
-notes = "View ▸ Fit to width (⌘2) and the rail's Page display menu's Fit to width scrolling (continuous scrolling at Fit width)"
-
-[[feature]]
-id = "view.zoom-fit-height"
-title = "Fit height"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"]
-notes = "View ▸ Fit to height"
-
-[[feature]]
-id = "view.zoom-fit-visible"
-title = "Fit visible content"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["view.fit_visible"]
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"]
-notes = "View ▸ Zoom ▸ Fit visible (⌘3): the current page's inked area (found by a quick low-resolution render) spans the window width; respects view rotation; drivable via the control channel's command"
-
-[[feature]]
-id = "view.marquee-zoom"
-title = "Marquee zoom tool"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["view.marquee_zoom"]
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"]
-notes = "drag to fill the window with an area; click to zoom in"
-
-[[feature]]
-id = "view.dynamic-zoom"
-title = "Dynamic zoom (drag to zoom)"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.loupe"
-title = "Loupe magnifier tool"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.pan-and-zoom"
-title = "Pan and zoom overview window"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.hand-tool"
-title = "Hand tool for panning"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"]
-notes = "drag to pan; Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily"
-
-[[feature]]
-id = "view.rotate-view"
-title = "Rotate the view without changing the file"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"]
-notes = ""
-
-[[feature]]
-id = "view.read-mode"
-title = "Read mode (hide tool chrome)"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.read_mode"]
-tools = []
-evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"]
-notes = ""
-
-[[feature]]
-id = "view.full-screen"
-title = "Full screen mode"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.full_screen"]
-tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves"]
-notes = ""
-
-[[feature]]
-id = "view.full-screen-presentation"
-title = "Full-screen presentation options (auto-advance, loop, click to advance)"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.reflow"
-title = "Reflow text to the window width"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.page-navigation"
-title = "Page navigation (next, previous, first, last, go to page)"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"]
-notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"
-
-[[feature]]
-id = "view.goto-page-label"
-title = "Go to a page by its label (logical page numbers)"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"]
-notes = "the page box takes labels (iv, A-1) before numbers"
-
-[[feature]]
-id = "view.view-history"
-title = "Previous view / next view"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["view.previous_view", "view.next_view"]
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/control.rs::routine_continuous_scrolling_does_not_add_navigation_history"]
-notes = "Per-tab page history: explicit search-result jumps, page navigation, bookmarks and links; no continuous-scroll history or restart persistence; existing page-step history is retained. Rail buttons, View ▸ Page navigation and Ctrl/⌘[ / Ctrl/⌘]. UI control ui.command exposes view.previous_view / view.next_view; standalone engine sessions have no viewport history. Restores page tops, not exact scroll/zoom positions."
-
-[[feature]]
-id = "view.follow-links"
-title = "Follow links and destinations"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = ["link_open"]
-evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords"]
-notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. URL/network file systems, GoToE, Launch and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit."
-
-[[feature]]
-id = "view.annotation-hover"
-title = "Show comment text on hover"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text"]
-notes = "a popup with the author and the comment text, as Acrobat shows"
-
-[[feature]]
-id = "view.page-size-hover"
-title = "Page size on hover (bottom-left of the page view)"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas.rs::page_size_hint_zone_is_the_bottom_left_quadrant"]
-notes = "While the pointer is in the bottom-left quadrant of the page view, a small label shows the current page's size (after /Rotate and /UserUnit) in the Preferences unit, formatted as in Document Properties (#1855). It never takes clicks and hides when the pointer leaves. Acrobat's trigger is the corner itself and may differ in exact area and wording (not compared with real Acrobat); the size of the current page only, not of the page under the pointer; no headless tool (page sizes are in document info)."
-
-[[feature]]
-id = "view.document-tabs"
-title = "Tabbed documents"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["view.next_tab", "view.previous_tab"]
-tools = ["doc_list"]
-evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch", "crates/ui-egui/tests/ui.rs::dragging_a_tab_moves_it_and_keeps_the_same_document_active", "crates/ui-egui/tests/ui.rs::a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it", "crates/ui-egui/tests/ui.rs::the_combine_tab_does_not_move_and_documents_stay_in_order", "crates/ui-egui/tests/commands.rs::ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around"]
-notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll. Document tabs are dragged to reorder them (#1537): a line marks where the tab will land, the strip scrolls at its edges when it overflows, and the same document stays active; the Combine files tab stays after the documents. Dragging a tab out into its own window is view.multiple-windows. Ctrl+Tab and Ctrl+Shift+Tab (⌃Tab and ⌃⇧Tab on macOS; View ▸ Next tab / Previous tab) step through the tabs in their order on screen, Combine files included, and wrap around at either end (#1542)."
-
-[[feature]]
-id = "view.multiple-windows"
-title = "Multiple windows; drag a tab out into a window"
-area = "B"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.split-window"
-title = "Split window (two panes on one document)"
-area = "B"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.spreadsheet-split"
-title = "Spreadsheet split (four synchronised panes)"
-area = "B"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.thumbnails-panel"
-title = "Page thumbnails panel"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"]
-notes = ""
-
-[[feature]]
-id = "view.thumbnail-context-ops"
-title = "Page operations from the thumbnails panel (drag reorder, context menu)"
-area = "B"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"]
-notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
-
-[[feature]]
-id = "view.bookmarks-panel"
-title = "Bookmarks panel with navigation"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = ["doc_info"]
-evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/view.rs::bookmark_destinations_scroll_to_their_position"]
-notes = "A bookmark goes to its destination's position and zoom, not just its page. The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)."
-
-[[feature]]
-id = "view.highlight-current-bookmark"
-title = "Highlight the current bookmark"
-area = "B"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.attachments-panel"
-title = "Attachments panel: list, open and save embedded files"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = ["doc_info"]
-evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted"]
-notes = ""
-
-[[feature]]
-id = "view.layers-panel"
-title = "Layers panel: toggle optional content visibility"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = ["doc_info"]
-evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits"]
-notes = ""
-
-[[feature]]
-id = "view.layers-reset"
-title = "Reset layers to initial visibility; list for all or current pages"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.destinations-panel"
-title = "Named destinations panel"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.articles-panel"
-title = "Articles panel and article reading"
-area = "B"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.model-tree"
-title = "3D model tree panel"
-area = "B"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.page-labels-display"
-title = "Show page labels (i, ii, 1, 2...)"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = ["doc_info"]
-evidence = ["crates/render/src/inspect.rs::roman_and_alpha_labels", "crates/automation/tests/automation.rs::open_inspect_render_and_find"]
-notes = ""
-
-[[feature]]
-id = "view.find"
-title = "Find text across the document"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["edit.find"]
-tools = ["text_find"]
-evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/ui.rs::find_reports_no_matches", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/text.rs::identity_v_columns_are_searchable_in_reading_order"]
-notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Complex interleaved flows still use a geometric reading-order heuristic."
-
-[[feature]]
-id = "view.find-next-previous"
-title = "Find next / previous match"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["edit.find"]
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages"]
-notes = ""
-
-[[feature]]
-id = "view.find-options"
-title = "Find options: whole words, case-sensitive"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["edit.find"]
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"]
-notes = "whole words only, case-sensitive"
-
-[[feature]]
-id = "view.find-in-comments-bookmarks"
-title = "Find in comments and bookmarks"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.find-results-panel"
-title = "Find results list"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["edit.advanced_search"]
-tools = ["text_find"]
-evidence = ["crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context"]
-notes = "Search panel (⇧⌘F): every match grouped by page with its context, whole words / case options, click to go; shares the find bar's search (closing the panel moves it to the bar)"
-
-[[feature]]
-id = "view.advanced-search"
-title = "Advanced search window"
-area = "B"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["edit.advanced_search"]
-tools = ["text_find"]
-evidence = ["crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context"]
-notes = "in the current document, as a side panel; searching other open documents, folders and indexes is still to come"
-
-[[feature]]
-id = "view.search-folder"
-title = "Search all PDFs in a folder"
-area = "B"
-tier = "P1"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.search-boolean"
-title = "Boolean, proximity and stemming search"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.search-index"
-title = "Build and search a full-text index across files"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.embedded-index"
-title = "Embedded search index"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.text-select"
-title = "Select text in reading order"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/ui_group/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/comments.rs::selected_text_comment_action_opens_note_composer"]
-notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection. The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar."
-
-[[feature]]
-id = "view.column-select"
-title = "Column select (Alt/Option-drag a rectangle of text)"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "shipped"
-commands = ["edit.column_select"]
-tools = ["text_extract"]
-evidence = ["crates/render/src/text.rs::a_column_selection_takes_one_table_column", "crates/render/src/text.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/ui_group/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/ui_group/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/ui_group/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/automation.rs::text_extract_with_a_rect_takes_one_column"]
-notes = "Issue #740. Holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, with the Select tool, the text markup tools and Redact (with Highlight and Redact the drag must start on text, since off text they draw an area box). Edit ▸ Column select does the same with a plain drag. A glyph counts when its centre is inside. Copy gives one row per visual line, top to bottom; pieces side by side (table cells) are separated by a tab, right to left in right-to-left rows; vertical text keeps reading order. text_extract takes the same rectangle as rect. Platforms: on Linux desktops whose window manager moves windows with Alt-drag (Xfce, Cinnamon and MATE by default; KDE Plasma 5 and other setups with Alt as the window modifier; GNOME and Plasma 6 use Super) the drag never reaches PdfCraft, so use Edit ▸ Column select or change that setting. On Windows, releasing Alt does not open a system menu (the window has no native menu). The web build has the same Linux caveat in the browser. Checked by hand on Linux (Fedora) with the Column select tool; Alt/Option-drag, and macOS and Windows generally, rest on the kittest tests. Missing: selection across pages, ⇧-click extending a column, a default shortcut, and a check of Acrobat's own copied-text format (not observed)."
-
-[[feature]]
-id = "view.copy-text"
-title = "Copy selected text"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["edit.copy"]
-tools = ["text_extract"]
-evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"]
-notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract."
-
-[[feature]]
-id = "view.select-all"
-title = "Select all text on a page"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"]
-notes = "⌘A on the current page"
-
-[[feature]]
-id = "view.copy-with-layout"
-title = "Copy text keeping layout"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.snapshot"
-title = "Snapshot tool (copy a region as an image)"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["edit.snapshot"]
-tools = ["page_render"]
-evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"]
-notes = "copies the area at the current zoom to the clipboard as an image"
-
-[[feature]]
-id = "view.copy-image"
-title = "Select an image on the page and copy it"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"]
-notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2/CCITT images, multi-format clipboard (file and bitmap at once), the web build"
-
-[[feature]]
-id = "view.rulers"
-title = "Rulers"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.grid"
-title = "Grid and snap to grid"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.guides"
-title = "Guides"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.line-weights"
-title = "Show line weights"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.cursor-coordinates"
-title = "Cursor coordinates"
-area = "B"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.theme-light-dark"
-title = "Light and dark themes"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["view.theme", "view.theme.light", "view.theme.dark"]
-tools = []
-evidence = ["crates/ui-egui/tests/ui_group/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme.rs::text_is_readable_on_every_surface"]
-notes = "Toolbar and Display theme share three choices and a preference setter; manual choices exit system mode. Every text colour meets WCAG AA (4.5:1) on every surface in both themes; glyphs are pixel-aligned for sharper text at 100–150 % scaling."
-
-[[feature]]
-id = "view.theme-system"
-title = "Follow the system theme"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["view.theme.system"]
-tools = []
-evidence = ["crates/ui-egui/tests/ui_group/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/ui_group/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/control.rs::theme_commands_and_options_report_preference_and_effective_colours"]
-notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately."
-
-[[feature]]
-id = "view.home-view"
-title = "Home view with recommended tools and recent files"
-area = "B"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["file.pin_folder"]
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/folders.rs::pinned_folders_survive_a_restart"]
-notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build."
-
-[[feature]]
-id = "view.all-tools"
-title = "All tools catalogue with milestone badges"
-area = "B"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::every_catalog_tool_is_listed_after_view_more", "crates/engine/src/commands.rs::every_ready_catalogue_item_is_a_registered_command"]
-notes = ""
-
-[[feature]]
-id = "view.command-palette"
-title = "Command palette (⌘K) over every command"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["view.palette"]
-tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::the_palette_lists_commands_with_shortcuts_and_runs_them"]
-notes = ""
-
-[[feature]]
-id = "view.keyboard-shortcuts"
-title = "Platform keyboard shortcuts for registered commands"
-area = "B"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions", "crates/ui-egui/tests/commands.rs::quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work", "crates/ui-egui/tests/signing.rs::ctrl_alt_q_types_an_email_without_quitting"]
-notes = "Quit requires exact Ctrl+Q/Cmd+Q modifiers and preserves unsaved-document confirmation. Ctrl+Alt+Q email input is covered with synthetic key/text events; native German keyboard-layout translation is not covered by that test (#1980)."
-
-[[feature]]
-id = "view.shortcuts-dialog"
-title = "Keyboard shortcuts reference dialog"
-area = "B"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["help.shortcuts"]
-tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::the_shortcuts_dialog_lists_the_real_bindings"]
-notes = ""
-
-[[feature]]
-id = "view.menus-from-registry"
-title = "Menus generated from the command registry"
-area = "B"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::the_pages_menu_comes_from_the_registry"]
-notes = ""
-
-[[feature]]
-id = "view.about-dialog"
-title = "About dialog"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "shipped"
-commands = ["help.about"]
-tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::the_about_dialog_shows_the_running_version"]
-notes = ""
-
-[[feature]]
-id = "view.check-for-updates"
-title = "Help ▸ Check for updates"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "partial"
-commands = ["help.check_updates"]
-tools = []
-evidence = ["crates/ui-egui/tests/ui_group/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/ui_group/updates.rs::nothing_is_asked_until_the_user_checks", "apps/pdfcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"]
-notes = "asks GitHub Releases on request and offers the release page. By the owner's decision there is no check at start and no in-place install (Acrobat has both)"
-
-[[feature]]
-id = "view.single-key-accelerators"
-title = "Single-key tool accelerators"
-area = "B"
-tier = "P2"
-milestone = "M3"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.customize-quick-tools"
-title = "Customise the quick-action toolbar and pinned tools"
-area = "B"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.restriction-notice"
-title = "Message bar for restricted documents"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes"]
-notes = ""
-
-[[feature]]
-id = "view.pdfa-view-mode"
-title = "Read-only view for PDF/A files with an Enable editing bar"
-area = "B"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.show-page-boxes"
-title = "Show art, trim and bleed boxes"
-area = "B"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "view.web-app"
-title = "Run in the browser (WebAssembly)"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/pickers.rs::a_startup_url_opens_in_front_when_the_user_has_not_started_working", "crates/ui-egui/tests/pickers.rs::a_late_startup_url_preserves_the_current_edit_workspace_and_save_target", "crates/ui-egui/tests/pickers.rs::deliberate_browser_file_opens_still_activate_the_new_tab"]
-notes = "web build works; a late startup ?file= download opens in a background tab after the user starts working, preserving the active document and Save target (#171). IndexedDB/OPFS recent list and download flow are M3.10"
-
-[[feature]]
-id = "view.large-document-performance"
-title = "Smooth scrolling on 500-page documents"
-area = "B"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget", "crates/ui-egui/tests/perf.rs::panels_with_hundreds_of_items_stay_within_the_frame_budget"]
-notes = "UI work per frame ~6 ms scrolling 500 pages (comments panel with 500 cards ~6 ms, thumbnails ~2 ms); rendering is off-thread and tiled"
-
-# ==================================================================================================
-# C. Content editing
-# ==================================================================================================
-
-[[feature]]
-id = "edit.text-edit"
-title = "Edit existing text in place"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "partial"
-commands = ["edit.edit_text"]
-tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
-evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"]
-notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md"
-
-[[feature]]
-id = "edit.text-reflow"
-title = "Reflow edited text inside its paragraph box"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::japanese_fallback_reports_the_face_after_save_and_reopen", "crates/edit/src/tests.rs::japanese_paragraph_keeps_ideographic_spaces", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
-notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin; U+3000 ideographic spaces are preserved (including paragraph edges and repeated spaces); Japanese Type 3 fallback reports its face through FontDescriptor/FontName after editing and save/reopen; source family/weight matching remains limited (#184)"
-
-[[feature]]
-id = "edit.text-move-resize"
-title = "Move and resize text boxes"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
-notes = "existing paragraphs: drag the box to move it, drag the handle on its right edge to rewrap it to a new width (upright pages); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)"
-
-[[feature]]
-id = "edit.paragraph-detection"
-title = "Detect paragraphs and text blocks for editing"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["text_paragraphs"]
-evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
-notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined"
-
-[[feature]]
-id = "edit.font-reuse-substitution"
-title = "Reuse embedded fonts or substitute with a warning"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/fonts/src/pdf.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf.rs::composite_fonts_use_to_unicode"]
-notes = "the line's font is reused when every character has a code and a glyph (ToUnicode, encodings, Differences; subset glyph checks); otherwise Helvetica (WinAnsi) is used and reported; text no font can show is refused"
-
-[[feature]]
-id = "edit.text-format-font"
-title = "Change font, size and colour"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text", "edit.text"]
-tools = ["text_edit", "content_update"]
-evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
-notes = "existing paragraphs and added text: font family (Helvetica, Times, Courier) with bold and italic, size, colour; existing paragraphs are rewrapped"
-
-[[feature]]
-id = "edit.text-format-style"
-title = "Bold, italic, underline"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
-notes = "bold and italic through the standard families; underline drawn under each line in the text colour"
-
-[[feature]]
-id = "edit.text-super-subscript"
-title = "Superscript and subscript"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.paragraph-alignment"
-title = "Paragraph alignment (left, centre, right, justify)"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text", "edit.text"]
-tools = ["text_edit", "content_update"]
-evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
-notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text"
-
-[[feature]]
-id = "edit.line-paragraph-spacing"
-title = "Line and paragraph spacing"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "partial"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
-notes = "line spacing (1, 1.15, 1.5, 2 × size or any multiple); space between paragraphs is not adjustable yet"
-
-[[feature]]
-id = "edit.char-spacing-scale"
-title = "Character spacing and horizontal scaling"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
-notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped"
-
-[[feature]]
-id = "edit.lists"
-title = "Bulleted and numbered lists"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.add-text"
-title = "Add a new text box as page content"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.text"]
-tools = ["page_add_text", "content_update"]
-evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"]
-notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
-
-[[feature]]
-id = "edit.find-replace"
-title = "Find and replace in edit mode"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.rtl-cjk-editing"
-title = "Edit right-to-left and CJK text"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "partial"
-commands = []
-tools = ["text_lines", "text_paragraphs", "text_edit"]
-evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip", "crates/edit/src/tests.rs::arabic_actual_text_keeps_typing_order_when_opened_for_editing", "crates/automation/tests/automation.rs::arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"]
-notes = "Added Arabic copy/search keeps typing order through per-line ActualText. Arabic/Hebrew ActualText is preserved in typing order rather than reordered twice; font-independent tests cover marks, numbers, Latin, reopening and complete replacement. This does not add Arabic fallback fonts or bidi caret/selection. Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK. Also (#1116): bounded single-face existing-text engine coverage now exists (Arabic marks/ligatures/mixed bidi and explicit full-face Chinese subset completion), with private keyboard/save/reopen checks. General mixed-font/style contextual runs, bidi caret/selection, legacy programs and release font availability remain incomplete, so broader feature remains planned"
-
-[[feature]]
-id = "edit.rotated-text-editing"
-title = "Edit rotated text"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.keep-tags-on-edit"
-title = "Keep structure tags when editing"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.image-add"
-title = "Add an image"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.image"]
-tools = ["page_add_image"]
-evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"]
-notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect"
-
-[[feature]]
-id = "edit.image-replace"
-title = "Replace an image (keep position and size)"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text", "edit.image"]
-tools = ["image_edit"]
-evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"]
-notes = "existing page images (right-click ▸ Replace Image) and added images: the new picture is drawn in the same place"
-
-[[feature]]
-id = "edit.image-crop"
-title = "Crop an image"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.image"]
-tools = ["content_update", "image_edit"]
-evidence = ["crates/ui-egui/tests/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"]
-notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed."
-
-[[feature]]
-id = "edit.image-rotate-flip"
-title = "Rotate and flip images"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["image_edit"]
-evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"]
-notes = "existing raster images and grouped Form artwork: rotate either way about the centre, flip horizontally or vertically (a q/cm/Q wrapper); Forms stay intact as groups; added images too"
-
-[[feature]]
-id = "edit.image-move-resize"
-title = "Move and resize images"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["image_edit", "image_hit"]
-evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"]
-notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
-
-[[feature]]
-id = "edit.image-extract"
-title = "Save an image to a file"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["image_save"]
-evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"]
-notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG"
-
-[[feature]]
-id = "edit.image-external-editor"
-title = "Edit an image in an external application"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.arrange-objects"
-title = "Arrange objects (bring forward, send backward)"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "partial"
-commands = []
-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"
-title = "Align and distribute objects"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.vector-edit"
-title = "Select, move, scale, recolour and delete vector paths"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.edit-object-tool"
-title = "Edit object tool for any page object"
-area = "C"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["edit.edit_text"]
-tools = ["object_list", "object_move"]
-evidence = ["crates/ui-egui/tests/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen"]
-notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md."
-
-[[feature]]
-id = "edit.object-properties"
-title = "Object properties (colour space, font, convert colour)"
-area = "C"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.link-create"
-title = "Create web and page links"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.link"]
-tools = ["link_add"]
-evidence = ["crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"]
-notes = "drag a rectangle; go to a page or open a web page"
-
-[[feature]]
-id = "edit.link-properties"
-title = "Link appearance and properties"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.link"]
-tools = ["link_edit"]
-evidence = ["crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"]
-notes = "visible/invisible rectangle, highlight, thickness, solid/dashed/underline and colour; supported page/URI action editing. Appearance-only edits preserve unsupported actions, including GoToR."
-
-[[feature]]
-id = "edit.links-from-urls"
-title = "Create links from URLs in the text"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.links_from_urls"]
-tools = ["links_from_urls"]
-evidence = ["crates/engine/src/tests.rs::links_from_urls_and_link_edits", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::urls_are_found_in_text"]
-notes = "http(s):// and www. addresses; already-linked text skipped"
-
-[[feature]]
-id = "edit.remove-all-links"
-title = "Remove all links"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.remove_links"]
-tools = ["links_remove"]
-evidence = [
- "crates/engine/src/tests.rs::links_from_urls_and_link_edits",
- "crates/automation/tests/automation.rs::links_through_tools",
- "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed",
- "crates/ui-egui/tests/ui_group/pdf_links.rs::removing_all_links_and_undo",
- "crates/ui-egui/tests/ui_group/pdf_links.rs::the_link_context_menu_shows_only_for_a_right_click_on_a_link",
-]
-notes = "removes all link annotations across the document or on specified pages (links_remove pages); a right-click on a link offers Properties, Delete and Remove all links; undoable, with a toast giving the count"
-
-[[feature]]
-id = "edit.action-goto"
-title = "Go-to-page-view action (with named destinations)"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "partial"
-commands = ["edit.link"]
-tools = ["link_add"]
-evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links"]
-notes = "go to a page (Fit view); named destinations and zoom pending"
-
-[[feature]]
-id = "edit.action-uri"
-title = "Open-a-web-link action"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.link"]
-tools = ["link_add"]
-evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools", "crates/ui-egui/tests/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/document_links.rs::a_button_uri_action_asks_first", "crates/ui-egui/tests/document_links.rs::an_email_link_cannot_attach_a_local_file", "crates/ui-egui/tests/document_links.rs::a_lookalike_host_is_shown_in_punycode_and_flagged"]
-notes = "Opening a document's link or button URI asks first; only http, https and mailto addresses are offered (#90, #91), and an email address that asks the email app to attach or insert a local file is refused. The prompt shows the host as the browser receives it: international hosts in punycode, lookalikes that mix alphabets flagged, numeric hosts in their usual form."
-
-[[feature]]
-id = "edit.action-launch"
-title = "Open-a-file action"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.action-named"
-title = "Execute-a-menu-item action (safe list)"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.action-set-ocg-state"
-title = "Set-layer-visibility action"
-area = "C"
-tier = "P2"
-milestone = "M12"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/ui_group/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/ui_group/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"]
-notes = "Links and push buttons run it: ON, OFF and Toggle in order, by optional content group, and with /PreserveRB (the default) a layer turned on turns off the rest of its /RBGroups group. Like the Layers panel it changes the view, not the document (no undo step, not saved). Not yet: creating the action (Link Properties, a field's Actions), bookmarks and other triggers, /Next chains, JavaScript ocg.state, and the Layers panel keeping radio-button groups itself."
-
-[[feature]]
-id = "edit.action-show-hide-field"
-title = "Show/hide-a-field action"
-area = "C"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/buttons.rs::hide_actions_hide_and_show_fields", "crates/forms/src/tests.rs::push_buttons_read_hide_actions"]
-notes = "A push button's Hide action (/T names, field or widget references, arrays; /H) hides or shows the fields and their kids; hidden widgets take no clicks, tab stops or highlights. A widget reference acts on its whole field, and only form fields are hidden, not other annotations."
-
-[[feature]]
-id = "edit.action-javascript"
-title = "Run-a-JavaScript action"
-area = "C"
-tier = "P1"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.action-thread"
-title = "Read-an-article action"
-area = "C"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.action-goto-embedded"
-title = "Go to a target in an embedded file (GoToE)"
-area = "C"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.action-media"
-title = "Play sound / media / rich-media actions"
-area = "C"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.header-footer-add"
-title = "Add header and footer"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.header_footer"]
-tools = ["doc_header_footer"]
-evidence = ["crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
-notes = "Helvetica only (no font choice yet); drawn in display space on rotated pages; artifact-tagged"
-
-[[feature]]
-id = "edit.header-footer-update-remove"
-title = "Update or remove header and footer"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.header_footer.update", "edit.header_footer.remove"]
-tools = ["doc_header_footer", "doc_remove_marks"]
-evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"]
-notes = "recognises PdfCraft marks; Acrobat-made headers are not detected yet"
-
-[[feature]]
-id = "edit.header-footer-tokens"
-title = "Page number and date tokens in header and footer"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.header_footer"]
-tools = ["doc_header_footer"]
-evidence = ["crates/edit/src/tests.rs::tokens_expand", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
-notes = "<<1>>, <>, <<1 of n>>, <>, dates, Bates"
-
-[[feature]]
-id = "edit.header-footer-ranges"
-title = "Header and footer page ranges (all, range, odd, even)"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.header_footer"]
-tools = ["doc_header_footer"]
-evidence = ["crates/ui-egui/src/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"]
-notes = "all / from–to, all / even / odd"
-
-[[feature]]
-id = "edit.header-footer-shrink"
-title = "Shrink document to avoid overwriting content"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.watermark-add-text"
-title = "Add a text watermark"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.watermark"]
-tools = ["doc_watermark"]
-evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/automation.rs::headers_watermarks_and_backgrounds_through_tools"]
-notes = "Helvetica; fit-to-page or a size"
-
-[[feature]]
-id = "edit.watermark-add-file"
-title = "Add a watermark from an image or PDF page"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.watermark"]
-tools = ["doc_watermark"]
-evidence = ["crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files"]
-notes = "an image or a page of a PDF, scaled relative to the page, rotated, with opacity, behind or on top"
-
-[[feature]]
-id = "edit.watermark-update-remove"
-title = "Update or remove watermarks"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.watermark.update", "edit.watermark.remove"]
-tools = ["doc_watermark", "doc_remove_marks"]
-evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
-notes = ""
-
-[[feature]]
-id = "edit.watermark-appearance"
-title = "Watermark rotation, opacity, scale, position, print/screen visibility"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "partial"
-commands = ["edit.watermark"]
-tools = ["doc_watermark"]
-evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/marks_ui.rs::the_watermark_edit_carries_the_offset"]
-notes = "rotation, opacity, behind/on top, and a horizontal/vertical offset from the page centre in points (dialog and offset_x/offset_y); no offset from the page edge or in page units, and no print/screen-only variants yet"
-
-[[feature]]
-id = "edit.background-add"
-title = "Add a page background (colour or file)"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.background"]
-tools = ["doc_background"]
-evidence = ["crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/ui_group/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"]
-notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page"
-
-[[feature]]
-id = "edit.background-update-remove"
-title = "Update or remove backgrounds"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.background.update", "edit.background.remove"]
-tools = ["doc_background", "doc_remove_marks"]
-evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
-notes = ""
-
-[[feature]]
-id = "edit.decoration-profiles"
-title = "Saved header/footer and watermark settings"
-area = "C"
-tier = "P2"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.bates-numbering"
-title = "Bates numbering"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "partial"
-commands = ["edit.bates", "edit.header_footer"]
-tools = ["doc_header_footer"]
-evidence = ["crates/edit/src/tests.rs::tokens_expand"]
-notes = "Bates ▸ Add opens Header & Footer with a Bates number; no multi-file batch yet"
-
-[[feature]]
-id = "edit.bates-multi-file"
-title = "Bates numbering across multiple files"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.bates-remove"
-title = "Remove Bates numbering"
-area = "C"
-tier = "P1"
-milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.spell-check"
-title = "Spell check in comments, form fields and edited text"
-area = "C"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.spell-dictionaries"
-title = "Spelling dictionaries per language and a user dictionary"
-area = "C"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "edit.format-panel"
-title = "Format panel for edited text"
-area = "C"
-tier = "P0"
-milestone = "M7"
-status = "shipped"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
-notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open"
-
-[[feature]]
-id = "edit.adobe-express-edits"
-title = "Adobe Express image edits (remove background, generative fill)"
-area = "C"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "cloud-only; images open in an external editor instead"
-
-# ==================================================================================================
-# D. Organize pages and documents
-# ==================================================================================================
-
-[[feature]]
-id = "organize.organize-grid"
-title = "Organize pages thumbnail grid"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.organize"]
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel"]
-notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection."
-evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"]
-notes = "Opening the Organize pages tool enters the overview directly. Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned."
-
-[[feature]]
-id = "organize.multi-select-pages"
-title = "Select pages with click, ⌘-click and ⇧-click"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.organize"]
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_select_all_shortcut_preserves_current_page_and_sets_range_anchor", "crates/ui-egui/tests/control.rs::select_all_key_selects_every_page_in_organize", "crates/ui-egui/tests/editing.rs::organize_page_range_tracks_thumbnails_and_applies_typed_selection", "crates/ui-egui/tests/editing.rs::organize_page_range_rejects_invalid_input_and_escape_cancels_without_clearing", "crates/ui-egui/tests/control.rs::state_reports_the_selected_pages"]
-notes = "Ctrl+A / ⌘A selects every page in the Organize grid, preserving the current page; text inputs and dialogs keep their own keyboard input. Page checkboxes also toggle selection without modifiers (organize_checkboxes_toggle_multiple_pages_without_modifiers). An editable physical page-range list stays synchronized with thumbnails; Enter, focus loss or Apply commits valid input, Escape cancels, and invalid input preserves the selection. UI control select accepts the same bounded ranges."
-
-[[feature]]
-id = "organize.rotate-pages"
-title = "Rotate pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.rotate", "page.rotate_ccw"]
-tools = ["page_rotate"]
-evidence = ["crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions"]
-notes = ""
-
-[[feature]]
-id = "organize.rotate-filters"
-title = "Rotate by range with even/odd and orientation filters"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.rotate_dialog"]
-tools = ["page_rotate"]
-evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
-notes = "Rotate Pages: all/selection/range; even/odd; landscape/portrait"
-
-[[feature]]
-id = "organize.delete-pages"
-title = "Delete pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.delete"]
-tools = ["page_delete"]
-evidence = ["crates/organize/src/tests.rs::delete_keeps_inherited_attributes_on_survivors", "crates/organize/src/tests.rs::cannot_delete_every_page_or_missing_pages", "crates/ui-egui/tests/editing.rs::delete_is_refused_when_it_would_remove_every_page"]
-notes = ""
-
-[[feature]]
-id = "organize.move-pages"
-title = "Move pages earlier or later"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = ["page_move"]
-evidence = ["crates/organize/src/tests.rs::move_pages_reorders", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it"]
-notes = ""
-
-[[feature]]
-id = "organize.drag-reorder"
-title = "Reorder pages by drag and drop"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::dragging_thumbnails_reorders_pages"]
-notes = "drag thumbnails (or the selection) in the page grid; an insertion bar shows where they go; move earlier/later buttons too"
-
-[[feature]]
-id = "organize.insert-blank"
-title = "Insert blank pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.insert_blank"]
-tools = ["page_insert_blank"]
-evidence = ["crates/organize/src/tests.rs::inserts_blank_page", "crates/ui-egui/tests/editing.rs::insert_blank_page_after_selection", "crates/engine/src/tests.rs::insert_blank_between_pages_keeps_both_neighbours"]
-notes = ""
-
-[[feature]]
-id = "organize.insert-from-file"
-title = "Insert pages from another file"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.insert"]
-tools = ["page_insert_file"]
-evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/organize/src/tests.rs::inserting_a_file_brings_its_bookmarks_under_one_named_after_it", "crates/automation/tests/automation.rs::inserting_a_file_brings_its_bookmarks", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"]
-notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The file's bookmarks come along, collapsed under one named after the file, placed among the document's bookmarks where the pages went (as Combine does per file). The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app; the file's tags (structure tree) are not merged."
-
-[[feature]]
-id = "organize.insert-from-clipboard"
-title = "Insert pages from the clipboard"
-area = "D"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.insert-from-scanner"
-title = "Insert pages from a scanner"
-area = "D"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.insert-from-web"
-title = "Insert pages from a web page"
-area = "D"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.insert-by-drop"
-title = "Insert by dropping files onto the page grid"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "partial"
-commands = ["page.organize"]
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::files_dropped_between_pages_are_inserted_there", "crates/ui-egui/tests/editing.rs::files_dropped_before_the_pointer_is_known_wait_for_it", "crates/ui-egui/tests/editing.rs::files_dropped_outside_the_page_grid_still_open_as_documents"]
-notes = "Desktop: PDFs, images and .txt files dropped on the Organize page grid are inserted at the gap under the pointer, in order, and selected (elsewhere a dropped file still opens as a document). While the files hover, a bar at the gap and a + badge follow the pointer; on macOS the pointer is read from AppKit, because the windowing layer sends no moves during a file drag. Missing: on Windows and Linux there is no live position, so the grid is only outlined and the files go to the gap the pointer is at right after the drop (or the end after a second); the web build (dropped files open as documents). Covered by simulated drop events; a real drag from Finder is not in the automated tests."
-
-[[feature]]
-id = "organize.extract-pages"
-title = "Extract pages to a new document"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.extract"]
-tools = ["page_extract"]
-evidence = ["crates/ui-egui/tests/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"]
-notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied."
-
-[[feature]]
-id = "organize.extract-separate-files"
-title = "Extract pages as separate files"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.extract"]
-tools = ["page_extract"]
-evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/ui-egui/tests/pickers.rs::extract_pages_saves_under_the_file_name_typed_in_the_dialog"]
-notes = "The dialog has a File name field (#737), filled in with the document's name: pages are written as ` (page N).pdf` into the chosen folder, and one renamed page as `.pdf`. Extracting into a new tab names the tab (and so Save As) after it."
-
-[[feature]]
-id = "organize.extract-and-delete"
-title = "Delete pages after extracting"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.extract"]
-tools = ["page_extract"]
-evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options"]
-notes = "undoable"
-
-[[feature]]
-id = "organize.replace-pages"
-title = "Replace pages with pages from another file"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.replace"]
-tools = ["page_replace"]
-evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"]
-notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept"
-
-[[feature]]
-id = "organize.duplicate-pages"
-title = "Duplicate pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.duplicate"]
-tools = ["page_duplicate"]
-evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last"]
-notes = "copies share fonts and images with the originals"
-
-[[feature]]
-id = "organize.reverse-pages"
-title = "Reverse page order"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.cut-copy-paste-pages"
-title = "Cut, copy and paste pages"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.copy", "page.cut", "page.paste"]
-tools = ["page_insert_file"]
-evidence = ["crates/ui-egui/tests/editing.rs::copying_cutting_and_pasting_pages"]
-notes = "⌘C/⌘X/⌘V and the thumbnail menu in Organize Pages; paste works across documents"
-
-[[feature]]
-id = "organize.drag-pages-between-documents"
-title = "Drag pages between documents"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.page-range-selection"
-title = "Select by range, odd/even, landscape/portrait"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "partial"
-commands = ["page.organize"]
-tools = ["page_rotate"]
-evidence = ["crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
-notes = "Organize ▸ Select: all, odd, even, landscape, portrait; ranges via the page tools"
-
-[[feature]]
-id = "organize.thumbnail-size"
-title = "Thumbnail size slider"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "shipped"
-commands = ["page.organize"]
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn"]
-notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. Missing: a slider as in Acrobat; the size is per document view and not remembered between sessions; the Pages side panel has no size control."
-
-[[feature]]
-id = "organize.split-every-n"
-title = "Split every N pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.split"]
-tools = ["doc_split"]
-evidence = ["crates/ui-egui/tests/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"]
-notes = "Each part carries the PDF/X output intent and identification (#263), as Extract does."
-
-[[feature]]
-id = "organize.split-before-pages"
-title = "Split before chosen pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.split"]
-tools = ["doc_split"]
-evidence = ["crates/ui-egui/tests/editing.rs::split_before_selected_pages"]
-notes = ""
-
-[[feature]]
-id = "organize.split-by-size"
-title = "Split by file size"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.split"]
-tools = ["doc_split"]
-evidence = ["crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
-notes = "greedy by single-page size (an over-estimate: parts stay under the limit)"
-
-[[feature]]
-id = "organize.split-by-bookmarks"
-title = "Split by top-level bookmarks"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.split"]
-tools = ["doc_split"]
-evidence = ["crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
-notes = "files named after the bookmarks"
-
-[[feature]]
-id = "organize.split-naming"
-title = "Name split parts after bookmarks; never overwrite"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.split-multiple-files"
-title = "Split multiple files in one run"
-area = "D"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.combine-files"
-title = "Combine files into one PDF"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.combine"]
-tools = ["doc_combine"]
-evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"]
-notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed."
-
-[[feature]]
-id = "organize.combine-bookmarks"
-title = "Combine adds a bookmark per file with its own bookmarks nested"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.combine"]
-tools = ["doc_combine"]
-evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments", "crates/organize/src/tests.rs::a_file_split_around_another_is_copied_once_with_one_bookmark", "crates/organize/src/tests.rs::grouped_runs_keep_their_order_repeats_and_separate_files", "crates/engine/src/tests.rs::a_file_split_around_another_combines_in_order_with_one_bookmark", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/automation/tests/automation.rs::combine_extract_and_split"]
-notes = "Also when a file's pages are split around other files' pages (Combine files grid): the file is copied once, so it keeps one bookmark at its first page, its nested bookmarks, links between its own pages, its form fields and one copy of its attachments. doc_combine does the same with groups."
-
-[[feature]]
-id = "organize.combine-choose-pages"
-title = "Choose which pages of each file to combine"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.combine"]
-tools = ["doc_combine"]
-evidence = ["crates/ui-egui/tests/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed", "crates/automation/tests/automation.rs::combine_extract_and_split"]
-notes = "A page range per file (such as 1-3, 6; in the order given), checked as it is typed, with the page count it takes; bookmarks to left-out pages lose their destination"
-
-[[feature]]
-id = "organize.combine-sort"
-title = "Sort and reorder files before combining"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "shipped"
-commands = ["page.combine"]
-tools = ["doc_combine"]
-evidence = ["crates/ui-egui/tests/editing.rs::column_headings_sort_the_list_and_flip_on_the_second_click", "crates/ui-egui/tests/editing.rs::ctrl_and_shift_clicks_select_several_files_that_move_and_go_together", "crates/ui-egui/tests/editing.rs::undo_and_redo_restore_the_combine_list", "crates/automation/tests/automation.rs::combine_extract_and_split"]
-notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag (rows in the list, cards in the grid); every change undoable. doc_combine combines in the order its paths are given."
-
-[[feature]]
-id = "organize.combine-thumbnails"
-title = "Thumbnail (grid) view of the files before combining"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "shipped"
-commands = ["page.combine"]
-tools = ["doc_combine"]
-evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"]
-notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 200% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; kept in the settings, --combine-zoom 60..200), keeping the card under the pointer in place. Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 768 px; at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file."
-
-[[feature]]
-id = "organize.combine-options"
-title = "Combine options (file size, convert to PDF/A, include open files)"
-area = "D"
-tier = "P2"
-milestone = "M11"
-status = "partial"
-commands = ["page.combine"]
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::open_documents_can_be_added_to_combine"]
-notes = "Include open files: Add open documents (with their unsaved changes). Missing: file-size options and converting the result to PDF/A."
-
-[[feature]]
-id = "organize.resource-dedupe"
-title = "Store identical fonts and images once when combining or inserting"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["page.combine", "page.insert"]
-tools = ["doc_combine"]
-evidence = ["crates/organize/src/tests.rs::combine_and_insert_share_identical_resources", "crates/organize/src/dedupe.rs::merges_bottom_up_and_keeps_identity_objects"]
-notes = ""
-
-[[feature]]
-id = "organize.preserve-links-dests"
-title = "Rewire links and named destinations to copied pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/organize/src/tests.rs::links_are_rewired_to_copied_pages_or_dropped", "crates/organize/src/tests.rs::named_destinations_are_resolved_to_the_copies"]
-notes = ""
-
-[[feature]]
-id = "organize.preserve-fields"
-title = "Keep form fields interactive when copying pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/organize/src/tests.rs::form_fields_come_along_and_are_registered"]
-notes = ""
-
-[[feature]]
-id = "organize.preserve-layers"
-title = "Keep layers and their default state when copying pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/organize/src/tests.rs::layers_are_registered_with_their_default_state"]
-notes = ""
-
-[[feature]]
-id = "organize.preserve-inherited"
-title = "Copy inherited page attributes with pages"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/organize/src/tests.rs::inherited_attributes_travel_with_copied_pages"]
-notes = ""
-
-[[feature]]
-id = "organize.preserve-attachments"
-title = "Keep attachments when combining"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments"]
-notes = ""
-
-[[feature]]
-id = "organize.field-name-conflicts"
-title = "Rename or merge colliding field names when combining"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.insert", "page.combine"]
-tools = ["doc_combine"]
-evidence = ["crates/organize/src/tests.rs::import_renames_a_colliding_field_name", "crates/organize/src/tests.rs::import_renames_to_the_first_free_variant", "crates/organize/src/tests.rs::import_renames_away_from_an_orphaned_widgets_name", "crates/organize/src/tests.rs::import_renames_a_nested_field_whose_full_name_collides", "crates/organize/src/tests.rs::import_renames_the_colliding_child_not_the_whole_tree", "crates/organize/src/tests.rs::import_renames_a_field_colliding_with_another_incoming_field", "crates/organize/src/tests.rs::import_can_merge_into_same_named_fields_instead", "crates/organize/src/tests.rs::a_renamed_field_tree_survives_a_save_and_reopen", "crates/organize/src/tests.rs::a_repeated_page_joins_the_renamed_field", "crates/organize/src/tests.rs::combine_renames_colliding_field_names_and_reports_them", "crates/organize/src/tests.rs::combine_grouped_renames_colliding_field_names", "crates/organize/src/tests.rs::combine_field_conflicts_previews_each_file_in_order", "crates/engine/src/tests.rs::insert_from_file_renames_colliding_fields_and_notes_it", "crates/engine/src/tests.rs::combine_reports_renamed_fields_and_can_merge", "crates/automation/tests/automation.rs::combine_renames_colliding_field_names", "crates/ui-egui/tests/editing.rs::combine_list_warns_that_colliding_field_names_will_be_renamed"]
-notes = "Collisions are found by fully-qualified name (parent.T.child), counting names that hang only off page widgets even when /AcroForm never lists them, and also between two incoming fields. The colliding field's own /T takes the first free variant (Text1-1, Text1-2, …), children keep their place, and nothing is silent: inserting into an open document notes each rename in the repair log (warning that scripts referring to the old names were not rewritten) and the undo step's label counts them; doc_combine lists renamed_fields in its result; the Combine tab's Warnings column previews the renames per file before combining. field_conflicts \"merge\" on doc_combine (and the engine policy) keeps the older one-field behaviour. Still missing: the Combine tab's preview considers whole files, not a narrowed page range; the UI offers no merge choice (it always renames); Create ▸ Multiple files renames without reporting; scripts and XFA bindings that reference old names are never rewritten; Acrobat's behaviour not observed."
-
-[[feature]]
-id = "organize.struct-tree-merge"
-title = "Merge structure trees when combining"
-area = "D"
-tier = "P1"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.page-labels-edit"
-title = "Edit page labels (style, prefix, start)"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.number"]
-tools = ["page_number"]
-evidence = ["crates/organize/src/tests.rs::number_pages_like_acrobat", "crates/engine/src/tests.rs::number_pages_shows_in_the_viewer_and_undoes", "crates/ui-egui/tests/bookmarks.rs::number_pages_dialog_labels_the_selected_pages", "crates/automation/tests/automation.rs::numbering_pages_through_tools"]
-notes = ""
-
-[[feature]]
-id = "organize.page-boxes"
-title = "Set page boxes (crop, trim, bleed, art)"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.boxes"]
-tools = ["page_set_box"]
-evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::set_page_boxes_dialog_applies_margins_to_the_chosen_pages", "crates/organize/src/tests.rs::page_boxes_default_inherit_and_set"]
-notes = "crop/trim/bleed/art/media, margins in pt/in/mm, page ranges, preview; no Remove white margins yet"
-
-[[feature]]
-id = "organize.crop-tool"
-title = "Crop pages by dragging a rectangle"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["page.crop"]
-tools = ["page_set_box"]
-evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select"]
-notes = "drag to crop; double-click opens Set Page Boxes"
-
-[[feature]]
-id = "organize.remove-white-margins"
-title = "Remove white margins"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.change-page-size"
-title = "Change page size"
-area = "D"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.page-transitions"
-title = "Page transitions"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "partial"
-commands = []
-tools = ["doc_info"]
-evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times"]
-notes = "Read only: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults for what is missing or out of range, and doc_info lists them (DocInfo::transitions). Not yet: playing them in full screen, auto-advancing on /Dur, or the Page Transitions dialog that sets them"
-tools = []
-evidence = []
-notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog"
-
-[[feature]]
-id = "organize.page-tab-order"
-title = "Page tab order property"
-area = "D"
-tier = "P1"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.page-actions"
-title = "Page open/close actions"
-area = "D"
-tier = "P2"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.page-templates"
-title = "Page templates"
-area = "D"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-add"
-title = "Add a bookmark (⌘B)"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["bookmark.add"]
-tools = ["bookmark_add"]
-evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"]
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-rename"
-title = "Rename bookmarks"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = ["bookmark_rename"]
-evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/bookmarks.rs::escape_cancels_a_rename", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"]
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-delete"
-title = "Delete bookmarks"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = ["bookmark_delete"]
-evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save"]
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-move"
-title = "Reorder and nest bookmarks"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = ["bookmark_move"]
-evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"]
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-set-destination"
-title = "Set a bookmark's destination"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = ["bookmark_set_page"]
-evidence = ["crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"]
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-appearance"
-title = "Bookmark style and colour"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-actions"
-title = "Bookmark actions"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.bookmark-expand-collapse"
-title = "Expand and collapse all bookmarks"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["file.properties"]
-tools = []
-evidence = ["crates/ui-egui/tests/bookmarks.rs::expanding_and_collapsing_all_bookmarks"]
-notes = "Bookmarks panel options: expand all, expand top-level, collapse all"
-
-[[feature]]
-id = "organize.bookmarks-from-structure"
-title = "Generate bookmarks from headings or tags"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["bookmark.from_structure"]
-tools = ["bookmark_from_structure"]
-evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"]
-notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it"
-
-[[feature]]
-id = "organize.attachments-add"
-title = "Add file attachments"
-area = "D"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.attachments-delete"
-title = "Delete attachments"
-area = "D"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.attachments-description"
-title = "Edit attachment descriptions"
-area = "D"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.associated-files"
-title = "Associated files with relationships (PDF/A-3, PDF 2.0)"
-area = "D"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.portfolio-view"
-title = "View PDF Portfolios as a file list"
-area = "D"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.portfolio-extract"
-title = "Extract files from a portfolio"
-area = "D"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.portfolio-create"
-title = "Create PDF Portfolios"
-area = "D"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.document-properties"
-title = "Document properties dialog (description, fonts, advanced)"
-area = "D"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_info"]
-evidence = ["crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/editing.rs::cancelling_properties_discards_the_draft"]
-notes = ""
-
-[[feature]]
-id = "organize.edit-metadata"
-title = "Edit title, author, subject and keywords"
-area = "D"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_set_info"]
-evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step"]
-notes = "When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)."
-
-[[feature]]
-id = "organize.fonts-list"
-title = "List fonts with type and embedding status"
-area = "D"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_info"]
-evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"]
-notes = "name, type, embedding, subset and encoding; doc_info also reports the face standing in for a program the document does not embed and why it was chosen (Acrobat's Actual Font), including the case where no licensed face exists so nothing is drawn. Still missing: the Fonts tab in the interface does not show the substituted face yet, only the data layer does"
-
-[[feature]]
-id = "organize.initial-view"
-title = "Edit initial view (layout, magnification, panels, window options)"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_initial_view"]
-evidence = ["crates/ui-egui/tests/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"]
-notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)"
-
-[[feature]]
-id = "organize.custom-properties"
-title = "Custom document properties"
-area = "D"
-tier = "P2"
-milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.xmp-editor"
-title = "Additional metadata (XMP) editor"
-area = "D"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.document-language"
-title = "Set document language and binding"
-area = "D"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_initial_view"]
-evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/automation.rs::initial_view_through_tools"]
-notes = "Advanced ▸ Reading Options: binding and language"
-
-[[feature]]
-id = "organize.print-presets"
-title = "Print dialog presets in document properties"
-area = "D"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "organize.base-url"
-title = "Base URL for relative links"
-area = "D"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-# ==================================================================================================
-# E. Comments and review
-# ==================================================================================================
-
-[[feature]]
-id = "comment.list-view"
-title = "Comments panel with threaded replies (read-only)"
-area = "E"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_list"]
-evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::page_groups_collapse_and_expand"]
-notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet"
-
-[[feature]]
-id = "comment.appearance-generation"
-title = "Generate correct appearances for every markup type"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "partial"
-commands = []
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn"]
-notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets); rich text not yet"
-
-[[feature]]
-id = "comment.sticky-note"
-title = "Sticky note comments"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.note"]
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
-notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline"
-
-[[feature]]
-id = "comment.highlight"
-title = "Highlight text"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.highlight"]
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/comments.rs::a_new_highlight_opens_its_note_for_typing", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
-notes = "drag over text, or pick the tool with text selected; a new highlight opens its note for typing in the Comments panel; agents can highlight by text search"
-
-[[feature]]
-id = "comment.underline"
-title = "Underline text"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.underline"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked"]
-notes = ""
-
-[[feature]]
-id = "comment.strikeout"
-title = "Strikethrough text"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.strikeout"]
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
-notes = ""
-
-[[feature]]
-id = "comment.squiggly"
-title = "Squiggly underline"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.squiggly"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::the_squiggly_tool_marks_selected_text"]
-notes = "Highlight ▸ Squiggly underline (with Underline and Strikethrough), the agent tool and the engine"
-
-[[feature]]
-id = "comment.replace-text"
-title = "Replace-text proposal (strikeout + caret)"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.replace"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
-notes = "strikeout (/IT StrikeOutTextEdit) and a grouped caret (/RT /Group) with the replacement"
-
-[[feature]]
-id = "comment.insert-text"
-title = "Insert-text proposal (caret)"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "partial"
-commands = ["comment.caret"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/comments.rs::inserted_text_snaps_to_the_gap_between_characters_on_the_baseline", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
-notes = "in the UI the caret snaps to the nearer character edge, on the baseline and sized to the text, on upright horizontal text of unrotated pages; elsewhere it stays at the click. comment_add places it where it is told (no snapping)"
-
-[[feature]]
-id = "comment.typewriter"
-title = "Add text comment (typewriter)"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["sign.fill.text"]
-tools = ["fill_sign_add"]
-evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/automation.rs::fill_sign_text_font_size_is_saved_and_validated"]
-notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates."
-
-[[feature]]
-id = "comment.text-box"
-title = "Text box comment with rich text"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "partial"
-commands = ["comment.freetext"]
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"]
-notes = "Helvetica text with colour, size and alignment; /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet"
-
-[[feature]]
-id = "comment.callout"
-title = "Callout comment"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.callout"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
-notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text"
-
-[[feature]]
-id = "comment.pencil"
-title = "Freehand drawing (ink)"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.ink"]
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::ink_strokes_are_smooth_curves_through_their_points", "crates/annot/src/tests.rs::drawings_resize_by_scaling_their_strokes"]
-notes = "one ink annotation per stroke; strokes are drawn as smooth curves through their points, as in Acrobat; resize with the 8 handles (the strokes scale into the new box)"
-
-[[feature]]
-id = "comment.eraser"
-title = "Eraser for ink strokes"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.eraser"]
-tools = []
-evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing"]
-notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step"
-
-[[feature]]
-id = "comment.line"
-title = "Line"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.line"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
-notes = ""
-
-[[feature]]
-id = "comment.arrow"
-title = "Arrow"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.arrow"]
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"]
-notes = ""
-
-[[feature]]
-id = "comment.rectangle"
-title = "Rectangle"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.square"]
-tools = ["comment_add", "comment_edit"]
-evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool"]
-notes = "resize with the 8 handles, straight after drawing with the tool still on (#2338); fill colour or no fill (#686) from the quick bar, Properties and comment_edit `fill`; fill swatches are the comment palette only (no custom colour picker yet)"
-
-[[feature]]
-id = "comment.oval"
-title = "Oval"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.circle"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
-notes = ""
-
-[[feature]]
-id = "comment.polygon"
-title = "Polygon"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.polygon"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
-notes = "click the corners; Enter, double-click or the first point finishes"
-
-[[feature]]
-id = "comment.polyline"
-title = "Connected lines (polyline)"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.polyline"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
-notes = ""
-
-[[feature]]
-id = "comment.cloud"
-title = "Cloud shape"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.cloud"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
-notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud)"
-
-[[feature]]
-id = "comment.area-highlight"
-title = "Area highlight"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.highlight"]
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::highlighting_an_area_off_the_text"]
-notes = "drag the highlighter off text to highlight a box"
-
-[[feature]]
-id = "comment.attach-file-comment"
-title = "Attach a file as a comment"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.attach"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
-notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel"
-
-[[feature]]
-id = "comment.record-audio"
-title = "Sound comments"
-area = "E"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.image-comment"
-title = "Add an image as a comment"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.line-endings"
-title = "All ten line-ending styles and dashed/cloudy borders"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "partial"
-commands = []
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::every_line_ending_is_drawn", "crates/automation/tests/automation.rs::line_endings_through_the_tool", "crates/ui-egui/tests/comments.rs::line_ending_properties_change_and_undo"]
-notes = "All ten /LE styles (None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash) are drawn on lines, polylines and callouts. comment_add endings and Comment properties set them. Dashed borders are drawn. Cloudy borders are still polygon-only."
-
-[[feature]]
-id = "comment.properties-dialog"
-title = "Comment properties (appearance, author, subject)"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_edit"]
-evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"]
-notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)"
-
-[[feature]]
-id = "comment.properties-bar"
-title = "Comment properties bar"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.note"]
-tools = ["comment_edit"]
-evidence = ["crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/ui-egui/tests/comments.rs::the_opacity_set_for_a_tool_goes_into_its_new_comments", "crates/ui-egui/tests/comments.rs::the_quick_bar_colour_changes_the_selected_comment", "crates/ui-egui/tests/comments.rs::the_quick_bar_colour_changes_the_selected_arrow"]
-notes = "quick-bar colour swatches immediately recolour a selected comment made with the active tool, and set the tool default; fill colour (rectangle, oval, polygon, cloud), opacity (10–100 %) and line thickness per tool; colour from the context menu"
-
-[[feature]]
-id = "comment.lock-comment"
-title = "Lock comments"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = []
-tools = ["comment_lock"]
-evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
-notes = "Comment Properties ▸ Locked; locked comments refuse move, resize, restyle and delete; text stays editable"
-
-[[feature]]
-id = "comment.default-properties"
-title = "Make current properties the tool default"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = []
-evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default", "crates/ui-egui/tests/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"]
-notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)"
-
-[[feature]]
-id = "comment.author-identity"
-title = "Author name from identity preferences"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = []
-tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/ui_group/identity.rs::the_identity_name_signs_new_comments_and_is_remembered"]
-notes = "Preferences ▸ Identity: the name on new comments (default the login name, as Acrobat), remembered across restarts"
-
-[[feature]]
-id = "comment.reply"
-title = "Reply to comments"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_reply"]
-evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"]
-notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted"
-
-[[feature]]
-id = "comment.set-status"
-title = "Set review status (accepted, rejected, cancelled, completed)"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_set_status"]
-evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone"]
-notes = "status replies (/State, /StateModel Review), shown as a badge"
-
-[[feature]]
-id = "comment.checkmark"
-title = "Mark comments with a checkmark"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = []
-tools = ["comment_mark"]
-evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
-notes = "hidden /StateModel /Marked reply; card toggle and context menu"
-
-[[feature]]
-id = "comment.delete-comment"
-title = "Delete comments"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_delete"]
-evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"]
-notes = "deletes the pop-up and the reply thread"
-
-[[feature]]
-id = "comment.edit-comment-text"
-title = "Edit comment text"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_edit"]
-evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"]
-notes = "double-click a comment or card, or Edit in the menus; Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)"
-
-[[feature]]
-id = "comment.copy-comment-text"
-title = "Copy comment text"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize"]
-notes = "card and canvas context menus"
-
-[[feature]]
-id = "comment.filter-comments"
-title = "Filter comments (type, author, status, colour, checkmark)"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_list"]
-evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts", "crates/ui-egui/tests/comments.rs::the_panel_filters_by_colour_and_checkmark"]
-notes = "type, author, status, colour and checkmark"
-
-[[feature]]
-id = "comment.sort-comments"
-title = "Sort comments (page, author, date, type, colour)"
-area = "E"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_list"]
-evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts"]
-notes = "page, author, date, type, colour, with group headers"
-
-[[feature]]
-id = "comment.search-comments"
-title = "Search comments"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "partial"
-commands = ["comment.list"]
-tools = ["comment_list"]
-evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"]
-notes = "search by author, text and type in the panel; not yet in the Find bar"
-
-[[feature]]
-id = "comment.show-hide-comments"
-title = "Show or hide all comments and pop-ups"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "partial"
-commands = ["comment.hide_all"]
-tools = ["comments_hide"]
-evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
-notes = "hide/show all comments (fields and links still draw); per-type hiding not yet"
-
-[[feature]]
-id = "comment.popups"
-title = "Open and reposition comment pop-ups"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.connector-lines"
-title = "Connector lines between markups and pop-ups"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.review-history"
-title = "Review history of state changes"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.list"]
-tools = ["comment_list"]
-evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author"]
-notes = "status changes listed in Comment Properties"
-
-[[feature]]
-id = "comment.stamps-standard"
-title = "Standard business stamps"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.stamp"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
-notes = "12 standard business stamps in PdfCraft's own design; ISO 32000 stamp names where defined. A stamp placed on a rotated page reads upright as the page is displayed (#1398)"
-
-[[feature]]
-id = "comment.stamps-sign-here"
-title = "Sign-here stamps"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.stamp"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
-notes = "Accepted, Initial Here, Rejected, Sign Here, Witness as pointed tags. The point stays on the left as the page is displayed, including /Rotate 90, 180 and 270 (#1398)"
-
-[[feature]]
-id = "comment.stamps-dynamic"
-title = "Dynamic stamps (name and date)"
-area = "E"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["comment.stamp"]
-tools = ["comment_add"]
-evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
-notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398)"
-
-[[feature]]
-id = "comment.stamps-custom"
-title = "Custom stamps from images or PDF pages"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.stamp"]
-tools = ["stamp_custom"]
-evidence = ["crates/ui-egui/tests/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::explicit_image_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_keep_displayed_orientation_on_rotated_pages"]
-notes = "Create custom stamp from a PDF page or an image (category and name), kept in the app's settings; placed at natural size (at most 200 pt) keeping its shape; resizable (appearance redrawn from /PCPicture); delete from the palette. Natural-size and explicit-rectangle images, and PDF-page stamps, read upright on a rotated page (#1398)"
-
-[[feature]]
-id = "comment.stamps-manage"
-title = "Manage stamp categories and imports"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.stamps-rotate"
-title = "Rotate stamps with a handle"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.import-fdf-xfdf"
-title = "Import comments from FDF/XFDF"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.import"]
-tools = ["doc_import_data"]
-evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
-notes = "replies, pop-ups, ink, quads, line ends; same /NM replaces"
-
-[[feature]]
-id = "comment.export-fdf-xfdf"
-title = "Export all comments to FDF/XFDF"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.export"]
-tools = ["doc_export_data"]
-evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
-notes = ""
-
-[[feature]]
-id = "comment.export-selected"
-title = "Export selected comments"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.import-from-pdf"
-title = "Import comments from another PDF"
-area = "E"
-tier = "P2"
-milestone = "M5"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.summarize"
-title = "Summarize comments into a new PDF"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "partial"
-commands = ["comment.summarize"]
-tools = ["comments_summarize"]
-evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
-notes = "Comments only layout, sorted by page, author, date or type; the layouts with the document and connector lines not yet"
-
-[[feature]]
-id = "comment.flatten"
-title = "Flatten comments into page content"
-area = "E"
-tier = "P1"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.flatten"]
-tools = ["doc_flatten"]
-evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"]
-notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted"
-
-[[feature]]
-id = "comment.migrate"
-title = "Migrate comments to a revised document"
-area = "E"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.measure-distance"
-title = "Distance measuring tool"
-area = "E"
-tier = "P2"
-milestone = "M12"
-status = "partial"
-commands = ["measure.distance"]
-tools = ["measure_distance"]
-evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
-notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested."
-
-[[feature]]
-id = "comment.measure-perimeter"
-title = "Perimeter measuring tool"
-area = "E"
-tier = "P2"
-milestone = "M12"
-status = "shipped"
-commands = ["measure.perimeter"]
-tools = ["measure_perimeter"]
-evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
-notes = "Connected-line lengths with click vertices and double-click/Enter finish. Repeat the first point to include a closing edge. Saved standard PolyLineDimension annotations."
-
-[[feature]]
-id = "comment.measure-area"
-title = "Area measuring tool"
-area = "E"
-tier = "P2"
-milestone = "M12"
-status = "shipped"
-commands = ["measure.area"]
-tools = ["measure_area"]
-evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
-notes = "Simple polygon areas, including concave boundaries and either winding. Self-intersecting boundaries are refused. Saved PolygonDimension annotations; no 3D surface measurement."
-
-[[feature]]
-id = "comment.measure-scale"
-title = "Scale ratio and calibration per viewport"
-area = "E"
-tier = "P2"
-milestone = "M12"
-status = "partial"
-commands = ["measure.scale"]
-tools = ["measure_scale"]
-evidence = ["crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip", "crates/measure/src/tests.rs::coordinates_roundtrip_for_crop_rotation_and_user_unit", "crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
-notes = "Persistent rectangular viewports with last-containing-viewport precedence, numeric scale and two-point calibration. First vertex selects the scale; prior annotations retain theirs. Partial: single-unit decimal rectilinear scales only; compound (ft-in), fractional and differing distance units can't be authored or imported, no viewport editing/removal UI, and interop with Acrobat's scale ratio dialog and its saved viewports is untested."
-
-[[feature]]
-id = "comment.measure-snap"
-title = "Snap to paths, endpoints, midpoints and intersections"
-area = "E"
-tier = "P2"
-milestone = "M12"
-status = "shipped"
-commands = ["measure.snap"]
-tools = ["measure_snap"]
-evidence = ["crates/automation/tests/automation.rs::measurement_snap_tool_covers_all_targets", "crates/measure/src/tests.rs::nested_forms_transforms_curves_and_cycles_are_bounded", "crates/measure/src/tests.rs::snap_paths_endpoints_midpoints_and_intersections"]
-notes = "Paths, endpoints, midpoints and intersections of painted vector geometry, including transformed Form XObjects. Cubics flattened to 0.2 user units. Bounded extraction and dense-intersection limits are reported. Raster/text outlines, clipping and optional-content visibility are not interpreted."
-
-[[feature]]
-id = "comment.measure-info-panel"
-title = "Measurement info panel"
-area = "E"
-tier = "P2"
-milestone = "M12"
-status = "shipped"
-commands = ["measure.info"]
-tools = ["measure_info", "measure_list"]
-evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
-notes = "Live value, units, X/Y deltas, angle and selected scale while drawing; saved measurements can be selected from the panel. Rectilinear 2D readings only."
-
-[[feature]]
-id = "comment.measure-export"
-title = "Export measurements as CSV"
-area = "E"
-tier = "P3"
-milestone = "M12"
-status = "shipped"
-commands = ["measure.export"]
-tools = ["measure_export"]
-evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::measurement_csv_quotes_newlines_and_blocks_formula_cells", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
-notes = "Atomic CSV export of page, annotation index, type, numeric value, unit, label, author and scale. Quotes embedded delimiters and prefixes spreadsheet formula triggers."
-
-[[feature]]
-id = "comment.geospatial"
-title = "Geospatial location and measuring"
-area = "E"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "comment.shared-review"
-title = "Send for comments / shared review"
-area = "E"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; exchange XFDF files or a pluggable review server instead"
-
-[[feature]]
-id = "comment.mentions-live-review"
-title = "@mentions, presence and live co-review"
-area = "E"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; no local equivalent"
-
-[[feature]]
-id = "comment.review-tracker"
-title = "Review tracker"
-area = "E"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud / legacy"
-
-[[feature]]
-id = "comment.enable-reader-commenting"
-title = "Enable commenting and measuring in Reader (usage rights)"
-area = "E"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "needs Adobe's private key; modern readers allow commenting anyway"
-
-# ==================================================================================================
-# F. Forms and JavaScript
-# ==================================================================================================
-
-[[feature]]
-id = "form.field-list"
-title = "Form fields panel listing every field and value"
-area = "F"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["form.fields"]
-tools = ["doc_info"]
-evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/ui_group/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/ui_group/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"]
-notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too"
-
-[[feature]]
-id = "form.highlight-fields"
-title = "Highlight existing fields"
-area = "F"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/view.rs::field_highlighting_is_remembered"]
-notes = "remembered for the next document and session; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it"
-
-[[feature]]
-id = "form.field-appearance-display"
-title = "Display checkbox, radio, list and signature widgets as designed"
-area = "F"
-tier = "P0"
-milestone = "M2"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/render/src/raster.rs::widget_appearance_states"]
-notes = ""
-
-[[feature]]
-id = "form.fill-text"
-title = "Fill text fields"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.fields"]
-tools = ["form_fill", "form_fields"]
-evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"]
-notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4)"
-evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"]
-notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)"
-
-[[feature]]
-id = "form.fill-checkbox-radio"
-title = "Toggle checkboxes and radio buttons (radios in unison)"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.fields"]
-tools = ["form_fill"]
-evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states"]
-notes = "NoToggleToOff honoured; widgets without appearances get our own"
-
-[[feature]]
-id = "form.fill-choice"
-title = "Choose from combo boxes and list boxes"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.fields"]
-tools = ["form_fill"]
-evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
-notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text"
-
-[[feature]]
-id = "form.push-buttons"
-title = "Push buttons run their actions"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "partial"
-commands = ["form.fields"]
-tools = []
-evidence = ["crates/ui-egui/tests/buttons.rs::buttons_reset_navigate_and_print", "crates/forms/src/af.rs::common_button_scripts_are_recognised", "crates/ui-egui/tests/javascript.rs::button_scripts_run_and_alert"]
-notes = "Reset form, Named (print, page navigation), URI, Go to, and any JavaScript (run in the sandboxed engine); submit explained, not sent"
-
-[[feature]]
-id = "form.field-navigation"
-title = "Tab between fields"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.fields"]
-tools = []
-evidence = ["crates/ui-egui/tests/forms.rs::tabbing_into_a_filled_field_selects_it_so_typing_replaces"]
-notes = "Tab / Shift+Tab move between text and choice fields in field order; entering with Tab selects the value"
-
-[[feature]]
-id = "form.appearance-regeneration"
-title = "Regenerate field appearances (auto-size, comb, multiline, password)"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "partial"
-commands = ["form.fields"]
-tools = ["form_fill", "form_set_props"]
-evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/prepare.rs::list_box_alignment_is_editable_and_preserved"]
-notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics"
-
-[[feature]]
-id = "form.rich-text-fields"
-title = "Rich-text field values"
-area = "F"
-tier = "P2"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.af-number"
-title = "Number and currency formatting (AFNumber)"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
-notes = "AFNumber_Format/Keystroke run natively: decimals, 5 separator styles, currency before/after, negative styles (red shown as minus/parentheses)"
-
-[[feature]]
-id = "form.af-percent"
-title = "Percentage formatting"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat"]
-notes = "AFPercent_Format/Keystroke"
-
-[[feature]]
-id = "form.af-date-time"
-title = "Date and time formatting and parsing"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
-notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused"
-
-[[feature]]
-id = "form.af-special"
-title = "Special formats (zip, phone, SSN, arbitrary mask)"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/af.rs::dates_times_and_masks"]
-notes = "zip, zip+4, phone, SSN, arbitrary masks (9 A O X)"
-
-[[feature]]
-id = "form.af-range-validate"
-title = "Range validation"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
-notes = "AFRange_Validate with Acrobat's messages"
-
-[[feature]]
-id = "form.af-simple-calculate"
-title = "Sum, product, average, min, max calculations"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
-notes = "SUM, PRD, AVG, MIN, MAX over fields and groups"
-
-[[feature]]
-id = "form.simplified-notation"
-title = "Simplified field notation calculations"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
-notes = "BVCALC expressions with + - * / and parentheses, escaped names"
-
-[[feature]]
-id = "form.custom-scripts"
-title = "Custom format, keystroke, validate and calculate scripts"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = ["form_set_script"]
-evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"]
-notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively."
-
-[[feature]]
-id = "form.calculation-order"
-title = "Calculation order"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
-notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form"
-
-[[feature]]
-id = "form.event-order"
-title = "Keystroke, validate, calculate, format event order"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = ["form_fill"]
-evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"]
-notes = "Keystroke (commit) \u2192 Validate \u2192 value \u2192 Calculate (in /CO order) \u2192 Format, for AF calls and custom scripts."
-
-[[feature]]
-id = "form.date-picker"
-title = "Date picker for date fields"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = []
-tools = ["form_fill"]
-evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar"]
-notes = "a month calendar under date fields; the pick is written in the field's own date format"
-
-[[feature]]
-id = "form.auto-complete"
-title = "Auto-complete from previous entries"
-area = "F"
-tier = "P2"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.required-fields"
-title = "Required-field highlighting"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = []
-tools = ["form_set_props"]
-evidence = ["crates/ui-egui/tests/view.rs::required_fields_get_a_red_border_when_highlighting"]
-notes = "required fields get a red border when fields are highlighted"
-
-[[feature]]
-id = "form.reset-form"
-title = "Clear or reset a form"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.clear"]
-tools = ["form_reset"]
-evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices"]
-notes = "back to /DV; undoable"
-
-[[feature]]
-id = "form.submit-form"
-title = "Submit form to a URL or email (with a prompt)"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.import-data"
-title = "Import form data (FDF, XFDF)"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.import_data"]
-tools = ["doc_import_data"]
-evidence = ["crates/xfdf/src/tests.rs::xfdf_import_keeps_text_around_xml_comments_and_instructions", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
-notes = "XFDF, FDF; XFDF text survives XML comments and processing instructions; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)"
-
-[[feature]]
-id = "form.export-data"
-title = "Export form data (FDF, XFDF)"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.export_data"]
-tools = ["doc_export_data"]
-evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
-notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)"
-
-[[feature]]
-id = "form.data-xml-csv-txt"
-title = "Form data as XML, CSV or tab-delimited text"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.export_data", "form.import_data"]
-tools = ["doc_export_data", "doc_import_data"]
-evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
-notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited"
-
-[[feature]]
-id = "form.merge-to-spreadsheet"
-title = "Merge form data files into a spreadsheet"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.merge_data"]
-tools = ["form_merge_data"]
-evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet"]
-notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file."
-
-[[feature]]
-id = "form.flatten-fields"
-title = "Flatten form fields"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.flatten"]
-tools = ["doc_flatten"]
-evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_a_valued_widget_it_cannot_draw", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"]
-notes = "widgets drawn into content and pruned from the field tree; a widget with no appearance to draw (none stored, or none for its selected state) stays a live field rather than losing its value, so a form with such fields is not fully flattened — Acrobat regenerates appearances first, which PdfCraft does not yet do here"
-
-[[feature]]
-id = "form.prepare-form-mode"
-title = "Prepare a form mode"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = ["form_add_field", "form_set_props", "form_delete_field"]
-evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"]
-notes = "fields shown with blue fill and name tags; select, move, resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits"
-
-[[feature]]
-id = "form.add-text-field"
-title = "Add text fields"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.text"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
-notes = "click for the default size or drag; named Text1, Text2…"
-
-[[feature]]
-id = "form.add-checkbox"
-title = "Add checkboxes"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.checkbox"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"]
-notes = "Check Box1…, Yes/Off appearance states"
-
-[[feature]]
-id = "form.add-radio"
-title = "Add radio buttons"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.radio"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
-notes = "new group Group1… or join the selected group with the next ChoiceN"
-
-[[feature]]
-id = "form.check-box-style"
-title = "Check box and radio button styles"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fields"]
-evidence = ["crates/forms/src/tests.rs::check_box_styles_change_the_mark", "crates/ui-egui/tests/prepare.rs::options_tab_sets_the_check_box_style", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
-notes = "Field Properties ▸ Options: check, circle, cross, diamond, square, star (/MK /CA, Acrobat's codes), drawn as paths; defaults check (check boxes) and circle (radio buttons)"
-
-[[feature]]
-id = "form.add-dropdown"
-title = "Add drop-down lists"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.combo"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
-notes = "Dropdown1…; items in Field Properties ▸ Options"
-
-[[feature]]
-id = "form.add-listbox"
-title = "Add list boxes"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.list"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
-notes = "List Box1…"
-
-[[feature]]
-id = "form.add-button"
-title = "Add push buttons"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.button"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
-notes = "Button1…; actions arrive with the JavaScript engine"
-
-[[feature]]
-id = "form.add-image-field"
-title = "Add image fields"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.image"]
-tools = ["form_add_field", "form_set_image"]
-evidence = ["crates/forms/src/tests.rs::image_fields_ask_for_a_picture_and_show_it", "crates/ui-egui/tests/prepare.rs::image_fields_take_a_picture_when_clicked", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
-notes = "icon-only push button with event.target.buttonImportIcon(), recognised natively; clicking asks for a PNG/JPEG/TIFF/GIF/BMP that becomes /MK /I, scaled proportionally and centred"
-
-[[feature]]
-id = "form.add-date-field"
-title = "Add date fields"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.date"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
-notes = "text field with AFDate_FormatEx(\"mm/dd/yyyy\") format/keystroke actions"
-
-[[feature]]
-id = "form.add-signature-field"
-title = "Add digital signature fields"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.add.signature"]
-tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
-notes = "unsigned /Sig field; signing is M9"
-
-[[feature]]
-id = "form.add-barcode-field"
-title = "Barcode fields (PDF417, QR, Data Matrix)"
-area = "F"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.props-general"
-title = "Field properties: general (name, tooltip, visibility, read-only, required)"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/ui-egui/tests/ui_group/bulk_fields.rs::locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction", "crates/ui-egui/tests/ui_group/bulk_fields.rs::visiting_shared_property_tabs_without_changes_does_not_dirty_the_document"]
-notes = "name, tooltip, read only, required, locked; shared tooltip/flags are opt-in, mixed values preserved, failures keep the draft and roll back the batch (visibility pending)"
-
-[[feature]]
-id = "form.props-appearance"
-title = "Field properties: appearance (border, fill, font)"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::appearance_properties_restyle_every_widget", "crates/ui-egui/tests/prepare.rs::appearance_tab_restyles_the_field", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::partial_appearance_keeps_custom_default_appearance_and_font_resources", "crates/forms/src/tests.rs::partial_appearance_preserves_each_widgets_indirect_colours_and_border_extensions", "crates/automation/tests/form_bulk.rs::bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save"]
-notes = "border/fill colour, line width, solid/dashed/beveled/inset/underline, text colour, Helvetica/Times/Courier, font size; shared GUI and partial tool edits preserve untouched custom fonts, widget styles and unknown keys. Appearance regeneration still uses approximate metrics"
-
-[[feature]]
-id = "form.props-position"
-title = "Field properties: position and size"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"]
-notes = "left/bottom/width/height in points; drag to move, corners to resize"
-
-[[feature]]
-id = "form.props-options"
-title = "Field properties: options per field type"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"]
-notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
-
-[[feature]]
-id = "form.props-actions"
-title = "Field properties: actions per trigger"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = ["form_actions", "form_set_actions"]
-evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/prepare.rs::actions_tab_adds_and_removes_actions"]
-notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept."
-
-[[feature]]
-id = "form.props-format"
-title = "Field properties: format"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
-notes = "None/Number/Percentage/Date/Time/Special with a live example; writes Acrobat's AF scripts"
-
-[[feature]]
-id = "form.props-validate"
-title = "Field properties: validate"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
-notes = "range from/to"
-
-[[feature]]
-id = "form.props-calculate"
-title = "Field properties: calculate"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.field.properties"]
-tools = ["form_set_props", "form_fill"]
-evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
-notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending"
-
-[[feature]]
-id = "form.props-selection-change"
-title = "Field properties: selection change"
-area = "F"
-tier = "P2"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.props-signed"
-title = "Field properties: signed (lock fields when signed)"
-area = "F"
-tier = "P1"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.align-distribute-fields"
-title = "Align, centre, distribute and match field sizes"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = ["form_set_props"]
-evidence = ["crates/ui-egui/tests/prepare.rs::aligning_distributing_and_sizing_several_fields"]
-notes = "Shift/⌘-click to select several; right-click: Align (left, right, top, bottom, centres), Distribute, Set Fields to Same Size; the right-clicked field is the anchor"
-
-[[feature]]
-id = "form.duplicate-fields"
-title = "Duplicate fields across pages"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = []
-evidence = ["crates/forms/src/tests.rs::duplicating_a_field_across_pages_shares_its_value", "crates/ui-egui/tests/prepare.rs::duplicating_a_field_onto_every_page"]
-notes = "right-click a field ▸ Duplicate: all pages or a range; one field, shared value"
-
-[[feature]]
-id = "form.multiple-copies"
-title = "Create multiple copies of a field in a grid"
-area = "F"
-tier = "P2"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.rename-lock-fields"
-title = "Rename and lock fields"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"]
-notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in"
-
-[[feature]]
-id = "form.tab-order"
-title = "Tab order (row, column, structure, manual)"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["form.tab_order.row", "form.tab_order.column", "form.tab_order.structure"]
-tools = ["form_tab_order"]
-evidence = ["crates/forms/src/tests.rs::tab_order_follows_the_page_setting", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::ordering_tabs_manually", "crates/ui-egui/tests/prepare.rs::the_fields_panel_orders_tabs_manually"]
-notes = "rows, columns, structure/annotation order (page /Tabs), and manual order from the Fields panel while preparing a form; honoured by Tab"
-
-[[feature]]
-id = "form.show-tab-numbers"
-title = "Show tab numbers"
-area = "F"
-tier = "P2"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.preview-form"
-title = "Preview mode for testing a form"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.prepare"]
-tools = []
-evidence = ["crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"]
-notes = "Preview / Edit fields toggle in the Prepare a form panel"
-
-[[feature]]
-id = "form.auto-detect-fields"
-title = "Automatic form field detection"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "partial"
-commands = ["form.detect", "form.prepare"]
-tools = ["form_detect_fields"]
-evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"]
-notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups."
-
-[[feature]]
-id = "form.auto-name-fields"
-title = "Name new fields from nearby labels"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["form.detect"]
-tools = ["form_detect_fields"]
-evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools"]
-notes = "Names come from the label to the left (or above; right of check boxes), unique like Acrobat's (Name2, Text1)."
-
-[[feature]]
-id = "form.document-javascripts"
-title = "Document-level JavaScripts"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["tools.document_js"]
-tools = ["js_document_scripts", "js_set_document_script"]
-evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"]
-notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts."
-
-[[feature]]
-id = "form.document-actions"
-title = "Document actions (will close, will save, did save, will print, did print)"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.js-host"
-title = "JavaScript engine with the Acrobat object model subset"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "partial"
-commands = ["tools.js_console"]
-tools = ["js_run"]
-evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"]
-notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
-
-[[feature]]
-id = "form.js-console"
-title = "JavaScript console"
-area = "F"
-tier = "P1"
-milestone = "M6"
-status = "shipped"
-commands = ["tools.js_console"]
-tools = ["js_run"]
-evidence = ["crates/ui-egui/tests/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/automation.rs::javascript_through_tools"]
-notes = "\u2318J: run JavaScript against the document, with console output, results and errors."
-
-[[feature]]
-id = "form.js-debugger"
-title = "JavaScript debugger"
-area = "F"
-tier = "P1"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.edit-all-javascripts"
-title = "Edit all JavaScripts in one editor"
-area = "F"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.js-security"
-title = "JavaScript enable switch and sandboxing"
-area = "F"
-tier = "P0"
-milestone = "M6"
-status = "shipped"
-commands = ["app.preferences"]
-tools = ["js_enabled"]
-evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/document_links.rs::a_script_cannot_open_other_kinds_of_address"]
-notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91)."
-
-[[feature]]
-id = "form.folder-level-js"
-title = "Folder-level JavaScript"
-area = "F"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "form.xfa-static"
-title = "View and fill static XFA forms"
-area = "F"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["file.open"]
-tools = ["doc_open", "form_fill"]
-evidence = ["crates/engine/src/tests.rs::static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back", "crates/ui-egui/tests/ui_group/xfa.rs::a_static_xfa_form_is_filled_from_its_data_and_says_so", "crates/xfa/src/tests.rs::static_forms_read_their_values_from_the_datasets", "crates/xfa/src/tests.rs::writing_values_keeps_the_data_no_field_binds_to", "crates/xfa/src/tests.rs::utf16_packets_are_read_and_written_back_as_utf16", "crates/engine/src/tests.rs::xfa_data_no_field_binds_to_survives_an_edit_and_save"]
-notes = "the AcroForm fields are shown and filled as in any form; on open their values come from the XFA datasets (what another viewer saved) by the fields' Designer names (recorded as a document warning), and every edit merges the values back into the datasets, keeping data no field binds to, so Acrobat shows the same values; values that can't be written are warnings. Missing: no template semantics (no scripts, calculations or hidden subforms), explicit bind refs are not followed (#60)"
-
-[[feature]]
-id = "form.xfa-dynamic"
-title = "View and fill dynamic XFA forms"
-area = "F"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["file.open"]
-tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
-notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
-
-[[feature]]
-id = "form.xfa-formcalc"
-title = "FormCalc scripting"
-area = "F"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["file.open"]
-tools = ["doc_open", "form_fill", "js_run"]
-evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved"]
-notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures"
-
-[[feature]]
-id = "form.xfa-data"
-title = "XFA XML data import and export"
-area = "F"
-tier = "P2"
-milestone = "M12"
-status = "partial"
-commands = ["file.open", "file.save", "form.export_data", "form.import_data"]
-tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"]
-evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"]
-notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
-
-[[feature]]
-id = "form.xfa-flatten"
-title = "Flatten XFA forms"
-area = "F"
-tier = "P2"
-milestone = "M12"
-status = "shipped"
-commands = ["form.flatten"]
-tools = ["doc_flatten", "xfa_to_static"]
-evidence = ["crates/xfa/src/tests.rs::stripping_takes_the_xfa_out_and_leaves_an_ordinary_document", "crates/engine/src/tests.rs::flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out", "crates/engine/src/tests.rs::an_xfa_form_converts_to_a_static_form_that_keeps_its_fields", "crates/automation/tests/automation.rs::xfa_forms_flatten_and_convert_to_static_through_tools"]
-notes = "Flatten form fields on a laid-out XFA form bakes the fields into the pages and takes the XFA out (packets, /NeedsRendering, PdfCraft's private keys), so every viewer, Adobe's included, shows the baked pages rather than laying the form out again from the template. xfa_to_static (tool and engine edit) keeps the laid-out pages and the fields with their values as an ordinary AcroForm whose scripts no longer run. Both undoable until saved. Missing: a menu entry for the conversion (the tool and the MCP server have it); flattening keeps the layout as PdfCraft laid it out, not as Acrobat would (form.xfa-dynamic)"
-
-[[feature]]
-id = "form.fill-sign-text"
-title = "Fill & Sign: add text on flat forms"
-area = "F"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["sign.fill.text"]
-tools = ["fill_sign_add"]
-evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"]
-notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet"
-
-[[feature]]
-id = "form.fill-sign-marks"
-title = "Fill & Sign: check, cross, dot, line marks"
-area = "F"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["sign.fill.check", "sign.fill.cross", "sign.fill.dot", "sign.fill.line"]
-tools = ["fill_sign_add"]
-evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn"]
-notes = "PdfCraft-drawn stamps"
-
-[[feature]]
-id = "form.fill-sign-date"
-title = "Fill & Sign: today's date"
-area = "F"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["sign.fill.date"]
-tools = ["fill_sign_add", "fill_sign_date_format"]
-evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/date_format.rs::fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered", "crates/engine/src/dates.rs::presets_write_the_day_in_their_own_order", "crates/engine/src/dates.rs::names_follow_the_date_language", "crates/ui-egui/tests/date_format.rs::month_names_follow_the_date_language_or_else_the_interface_language", "crates/ui-egui/tests/date_format.rs::a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first"]
-notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 12 presets or any pattern in Acrobat date letters with \\ escapes, checked and previewed as typed, remembered; only whole date codes accepted; month and weekday names in the interface languages, Czech months in the genitive after a day, following the interface language unless one is picked; fill_sign_add takes a one-off format and language). Beyond Acrobat: its Fill & Sign has no format setting (only Acrobat Sign accounts do). Missing: Fill & Sign text is Helvetica/WinAnsi, so a date with letters outside Western European (Czech ř, Japanese, Chinese names) is refused with a warning instead of being saved as ?; the OS locale's format isn't offered as a preset; names in languages without an interface translation"
-
-[[feature]]
-id = "form.fill-sign-signature"
-title = "Fill & Sign: typed, drawn, image or scanned signature and initials"
-area = "F"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"]
-tools = ["fill_sign_add", "comment_edit", "comment_image_preview"]
-evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones"]
-notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)"
-evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour"]
-notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)"
-
-[[feature]]
-id = "form.fill-sign-flatten"
-title = "Fill & Sign: flatten on save"
-area = "F"
-tier = "P0"
-milestone = "M5"
-status = "shipped"
-commands = ["comment.flatten"]
-tools = ["doc_save", "doc_flatten"]
-evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on"]
-notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable. Other comments and form fields stay. Flatten comments still flattens every comment."
-
-[[feature]]
-id = "form.autofill-profile"
-title = "Autofill collection synced to an Adobe account"
-area = "F"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; a local profile may come later"
-
-[[feature]]
-id = "form.distribute-track"
-title = "Distribute and track forms, collect responses"
-area = "F"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; only submit-to-URL/email actions are supported"
-
-[[feature]]
-id = "form.web-forms"
-title = "Acrobat Sign web forms"
-area = "F"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud"
-
-# ==================================================================================================
-# G. Protect, redact, sanitize
-# ==================================================================================================
-
-[[feature]]
-id = "protect.security-properties"
-title = "Security properties (method, which password opened it, each permission)"
-area = "G"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = ["protect.properties"]
-tools = ["doc_info"]
-evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details"]
-notes = ""
-
-[[feature]]
-id = "protect.password-to-open"
-title = "Protect with a password to open"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.password"]
-tools = ["doc_protect"]
-evidence = ["crates/ui-egui/tests/ui_group/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted", "crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"]
-notes = "Protect Using Password (Viewing); applied by the next save"
-
-[[feature]]
-id = "protect.permissions-password"
-title = "Restrict editing and printing with a permissions password"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.password"]
-tools = ["doc_protect"]
-evidence = ["crates/ui-egui/tests/ui_group/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session", "crates/automation/tests/automation.rs::protecting_through_tools"]
-notes = "Protect Using Password (Editing); the author keeps full rights after saving"
-
-[[feature]]
-id = "protect.permissions-matrix"
-title = "Choose printing, changes, copying and accessibility permissions"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.password"]
-tools = ["doc_protect"]
-evidence = ["crates/ui-egui/tests/ui_group/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session"]
-notes = "Advanced Options: printing (none/low/high), changes (5 levels), copying, screen readers"
-
-[[feature]]
-id = "protect.encryption-level"
-title = "Choose encryption compatibility (RC4 40/128, AES-128, AES-256)"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.password"]
-tools = ["doc_protect"]
-evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"]
-notes = "AES-256 (default), AES-128, RC4-128, RC4-40"
-
-[[feature]]
-id = "protect.encrypt-except-metadata"
-title = "Encrypt everything except metadata"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.password"]
-tools = ["doc_protect"]
-evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"]
-notes = "Advanced Options; disabled for RC4 levels as in Acrobat"
-
-[[feature]]
-id = "protect.encrypt-attachments-only"
-title = "Encrypt only file attachments"
-area = "G"
-tier = "P2"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.password-strength"
-title = "Password strength meter"
-area = "G"
-tier = "P2"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.password"]
-tools = []
-evidence = ["crates/ui-egui/tests/ui_group/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted"]
-notes = "Weak / Medium / Strong under the password field"
-
-[[feature]]
-id = "protect.remove-security"
-title = "Remove security"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.remove"]
-tools = ["doc_unprotect"]
-evidence = ["crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"]
-notes = "needs the permissions password (or none set)"
-
-[[feature]]
-id = "protect.certificate-encryption"
-title = "Encrypt with certificates for recipients"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.recipient-permissions"
-title = "Per-recipient permissions for certificate security"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.recipient-ldap"
-title = "Find recipients in a directory (LDAP)"
-area = "G"
-tier = "P3"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.security-policies"
-title = "Local security policies (saved presets)"
-area = "G"
-tier = "P2"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.rights-management"
-title = "Server-based rights management (AEM/LiveCycle)"
-area = "G"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "server product; local policy presets only"
-
-[[feature]]
-id = "protect.mip-labels"
-title = "Open Microsoft Purview-protected PDFs and apply labels"
-area = "G"
-tier = "P3"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "optional Windows plug-in"
-
-[[feature]]
-id = "protect.redact-text"
-title = "Mark text for redaction"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.mark"]
-tools = ["redact_mark"]
-evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put"]
-notes = "drag across text (or select first) with the Redact tool"
-
-[[feature]]
-id = "protect.redact-area"
-title = "Mark areas and images for redaction"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.mark"]
-tools = ["redact_mark"]
-evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"]
-notes = "drag a box anywhere else"
-
-[[feature]]
-id = "protect.redact-pages"
-title = "Mark whole pages for redaction"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.pages"]
-tools = ["redact_mark"]
-evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
-notes = "Mark Page Range: current page or a range"
-
-[[feature]]
-id = "protect.search-and-redact"
-title = "Find text and redact"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.search"]
-tools = ["redact_mark"]
-evidence = ["crates/automation/tests/automation.rs::redacting_through_tools"]
-notes = "single word or phrase, every match on every page"
-
-[[feature]]
-id = "protect.redact-word-lists"
-title = "Redact lists of words or phrases"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.search"]
-tools = ["redact_mark"]
-evidence = ["crates/redact/src/patterns.rs::word_lists_are_trimmed_unique_and_bounded", "crates/automation/tests/automation.rs::redacting_word_lists_through_tools", "crates/automation/tests/automation.rs::redaction_search_respects_case_and_word_boundaries", "crates/ui-egui/tests/ui_group/redact.rs::redacting_an_imported_word_list", "crates/ui-egui/tests/ui_group/redact.rs::redaction_search_can_match_case_and_whole_words"]
-notes = "Find text and redact ▸ Multiple words or phrases: one per line, typed or imported from a .txt file (at most 1 MB, 1,000 entries of 256 characters); every match of each is marked in one undo step; redact_mark words reports marks per word. Single phrase and word-list searches offer case-sensitive and whole-words-only options in the UI and headless tool. Not yet: exporting the list"
-
-[[feature]]
-id = "protect.redact-patterns"
-title = "Redact patterns (phone, email, card, SSN, dates)"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.search"]
-tools = ["redact_mark"]
-evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"]
-notes = "phone (North American, E.164 with + country code, national with trunk 0), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded"
-
-[[feature]]
-id = "protect.redact-pattern-locales"
-title = "Pattern sets per locale"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.redact-partial-words"
-title = "Redact part of a match (e.g. all but the last four digits)"
-area = "G"
-tier = "P2"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.redact-folder"
-title = "Search and redact across a folder"
-area = "G"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.redaction-properties"
-title = "Redaction fill colour and overlay text"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.properties"]
-tools = ["redact_mark"]
-evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_survive_a_restart", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_for_this_run_only_and_cancel", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"]
-notes = "Acrobat's three groups: redacted area fill colour or none; overlay text with font, size or auto-size, colour, alignment and repeat, or redaction codes (protect.redaction-codes); Redaction mark appearance: outline colour (/C), fill colour shown while the pointer is over a mark (a preference, not written), opacity (/CA). Make properties default: ticked, OK keeps the values for the next run (settings \"redact_prefs\"); unticked, this run only and the saved defaults stay; Cancel puts back what the dialog opened with."
-
-[[feature]]
-id = "protect.redaction-codes"
-title = "Redaction code sets (e.g. FOIA exemptions)"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "partial"
-commands = ["redact.properties"]
-tools = ["redact_mark"]
-evidence = ["crates/redact/src/tests.rs::redaction_codes_join_in_set_order_and_reject_strangers", "crates/automation/tests/automation.rs::redacting_with_codes_through_tools", "crates/ui-egui/tests/ui_group/redact.rs::redaction_codes_become_the_overlay_text"]
-notes = "built-in U.S. FOIA (5 U.S.C. 552(b)) and U.S. Privacy Act (552a) sets; picked codes become the overlay text, in the set's order. Missing: Edit Codes, custom code sets, import/export of code sets"
-
-[[feature]]
-id = "protect.apply-redactions"
-title = "Apply redactions (remove underlying content)"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.apply"]
-tools = ["redact_apply"]
-evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"]
-notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns"
-
-[[feature]]
-id = "protect.redact-glyph-removal"
-title = "Remove redacted text glyphs from content streams"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.apply"]
-tools = ["redact_apply"]
-evidence = ["crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put", "crates/redact/src/tests.rs::kerning_spacing_scaling_and_line_operators_are_honoured", "crates/redact/src/tests.rs::rotated_text_and_composite_fonts", "crates/redact/src/tests.rs::shared_form_xobjects_are_copied_not_changed"]
-notes = "TJ displacement keeps the rest in place; simple, composite (CMaps), Type 3 fonts"
-
-[[feature]]
-id = "protect.redact-image-pixels"
-title = "Remove redacted image pixels"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.apply"]
-tools = ["redact_apply"]
-evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"]
-notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole"
-
-[[feature]]
-id = "protect.redact-vectors"
-title = "Remove or cut redacted vector paths"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.apply"]
-tools = ["redact_apply"]
-evidence = ["crates/redact/src/tests.rs::vectors_are_removed_or_clipped_and_inline_images_go"]
-notes = "covered paths removed, others clipped; shadings clipped"
-
-[[feature]]
-id = "protect.redact-annots-fields"
-title = "Remove annotations and fields under redactions"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.apply"]
-tools = ["redact_apply"]
-evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
-notes = "with pop-ups; fields lose all widgets"
-
-[[feature]]
-id = "protect.redact-struct-tree"
-title = "Remove redacted content from tags and alternate text"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.apply"]
-tools = ["redact_apply"]
-evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed"]
-notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; untouched tags stay"
-
-[[feature]]
-id = "protect.redaction-verification"
-title = "Verify no residue remains after redaction"
-area = "G"
-tier = "P0"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.apply"]
-tools = ["redact_apply"]
-evidence = ["crates/redact/src/tests.rs::unreadable_content_fails_closed", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo"]
-notes = "re-reads redacted pages; residue or unreadable content fails the whole operation"
-
-[[feature]]
-id = "protect.redact-filename-suffix"
-title = "Add a suffix to redacted file names"
-area = "G"
-tier = "P3"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.redact-ai-suggestions"
-title = "Suggest sensitive content to redact via an AI provider"
-area = "G"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "local or user-provided model; off by default"
-
-[[feature]]
-id = "protect.remove-hidden-information"
-title = "Remove hidden information (selectable categories)"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "shipped"
-commands = ["protect.remove_hidden"]
-tools = ["doc_hidden_info", "doc_remove_hidden"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"]
-notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending"
-
-[[feature]]
-id = "protect.sanitize"
-title = "Sanitize document (remove all hidden information)"
-area = "G"
-tier = "P1"
-milestone = "M8"
-status = "shipped"
-commands = ["redact.sanitize"]
-tools = ["doc_remove_hidden"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"]
-notes = "every category, then a full rewrite on save"
-
-[[feature]]
-id = "protect.audit-faux-redactions"
-title = "Audit: detect faux redactions (opaque covers over extractable text, unapplied marks)"
-area = "G"
-tier = "P2"
-milestone = "M8"
-status = "partial"
-commands = []
-tools = ["doc_audit_redactions"]
-evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"]
-notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)."
-evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"]
-notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
-
-[[feature]]
-id = "protect.remove-hidden-on-close"
-title = "Remove hidden information when closing or sending"
-area = "G"
-tier = "P3"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.attachment-trust"
-title = "Block opening risky attachment types"
-area = "G"
-tier = "P1"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.url-access-control"
-title = "Control internet access from PDFs"
-area = "G"
-tier = "P1"
-milestone = "M6"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.protected-view"
-title = "Open untrusted files in a restricted view"
-area = "G"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "protect.security-envelope"
-title = "Security envelopes"
-area = "G"
-tier = "P3"
-milestone = "M8"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "legacy"
-
-# ==================================================================================================
-# H. Digital signatures
-# ==================================================================================================
-
-[[feature]]
-id = "sign.pkcs12"
-title = "Sign with a PKCS#12 (.p12/.pfx) digital ID"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/crypto.rs::opens_every_openssl_flavour_of_pkcs12", "crates/sign/tests/crypto.rs::opens_pkcs12_as_windows_writes_it", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "PBES2 (AES, 3DES) and legacy SHA-1 3DES/RC2 files; RSA, P-256, P-384; both the DER OpenSSL writes and the BER Windows writes (indefinite lengths, segmented AuthenticatedSafe)"
-
-[[feature]]
-id = "sign.macos-keychain"
-title = "Sign with macOS Keychain identities"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_keychain_ids", "sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::signing_with_keychain_identities", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "Security framework identities (RSA PKCS #1 v1.5, ECDSA P-256/P-384) listed with the digital IDs in Sign with a Digital ID; the key never leaves the Keychain (ExternalKey); keychain: for agents. The test uses a throwaway keychain file (run with --ignored)"
-
-[[feature]]
-id = "sign.windows-cert-store"
-title = "Sign with the Windows certificate store"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital", "sign.certify"]
-tools = ["sign_windows_ids", "sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen"]
-notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11."
-
-[[feature]]
-id = "sign.encrypted-documents"
-title = "Sign and timestamp password-protected (encrypted) documents"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital", "sign.certify"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::encrypted_documents_are_signed_with_an_unencrypted_contents", "crates/sign/tests/pdf.rs::encrypted_documents_are_signed_only_as_their_permissions_allow", "crates/sign/tests/pdf.rs::encrypted_documents_take_a_document_timestamp", "crates/cos/tests/encryption.rs::signature_contents_stay_unencrypted", "crates/automation/tests/automation.rs::signing_an_encrypted_form_through_tools"]
-notes = "RC4-40/128, AES-128 and AES-256 Standard security; the incremental update keeps /Encrypt and /ID, encrypts the new strings and streams, and leaves the signature /Contents unencrypted (§7.6.2), which reading also honours. Opened with the user password, permissions (Table 22) apply: existing signature fields need fill-forms, new fields need annotate + modify, certifying needs modify, a document timestamp needs fill-forms; the owner may do anything. Not yet: certificate (public-key) security, which PdfCraft can't open; checked only against PdfCraft's own validator, not yet against an external one (pyHanko) or Acrobat"
-
-[[feature]]
-id = "sign.pkcs11"
-title = "Sign with PKCS#11 tokens and smart cards"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.self-signed-id"
-title = "Create a self-signed digital ID"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_id_create"]
-evidence = ["crates/sign/tests/crypto.rs::new_digital_ids_are_self_signed_and_survive_a_p12_round_trip", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "RSA 2048/3072/4096 or P-256, saved as a password-protected .p12"
-
-[[feature]]
-id = "sign.export-certificate"
-title = "Export a public certificate"
-area = "H"
-tier = "P2"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.panel"]
-tools = []
-evidence = ["crates/sign/tests/crypto.rs::certificates_load_from_pem_and_der_and_export_as_pem"]
-notes = "Signatures panel ▸ Export certificate (.cer, PEM)"
-
-[[feature]]
-id = "sign.visible-signature"
-title = "Visible signature by drawing a rectangle"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_document"]
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "drag a rectangle; Acrobat's name-and-details appearance"
-
-[[feature]]
-id = "sign.sign-existing-field"
-title = "Sign an existing signature field"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it"]
-notes = ""
-
-[[feature]]
-id = "sign.invisible-signature"
-title = "Invisible signature"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.certify_invisible"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::invisible_signatures_and_refusals", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"]
-notes = ""
-
-[[feature]]
-id = "sign.signature-appearance"
-title = "Configure signature appearance (text, graphic, name)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = ["sign.digital"]
-tools = ["sign_document"]
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools", "crates/sign/tests/pdf.rs::appearance_labels_helvetica_cannot_draw_are_refused", "crates/ui-egui/tests/signing.rs::the_signature_labels_follow_the_interface_language", "crates/automation/tests/automation.rs::signing_with_translated_labels_through_tools"]
-notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. The labels are stamped in the interface language when Helvetica (WinAnsi) can draw all of them (German, Spanish, French, Italian, Portuguese, Polish, Turkish, Slovene, Hungarian), otherwise all in English with a note under the preview, which shows the same labels and the signing time; sign_document takes labels and the shown items (appearance), and a drawn label the font can't draw is refused, never written as ?. Missing: labels in non-Latin scripts (needs an embedded font), the DN line in the preview, named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog"
-
-[[feature]]
-id = "sign.reason-location"
-title = "Reason, location and contact info"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = ""
-
-[[feature]]
-id = "sign.lock-after-signing"
-title = "Lock document or fields after signing"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.pkcs7-detached"
-title = "PKCS#7 detached signatures"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.validate"]
-tools = ["sign_list"]
-evidence = ["crates/sign/tests/pdf.rs::validates_a_signature_made_by_openssl", "crates/sign/tests/pdf.rs::validates_signatures_written_with_ber_indefinite_lengths", "crates/sign/tests/ber.rs::deeply_nested_segmented_content_is_an_error_not_a_stack_overflow", "crates/sign/tests/ber.rs::indefinite_length_nesting_bombs_are_refused"]
-notes = "validated, including BER-encoded CMS (indefinite lengths and segmented strings, as DocuSign, Documenso, Adobe PPKMS and Windows write it; the BER is reported in the details); new signatures use CAdES. Not read: BER high tag numbers. Still missing: multiple SignerInfos, counter-signatures, CMS with the signer's cert referenced but absent"
-
-[[feature]]
-id = "sign.cades-pades-bb"
-title = "CAdES / PAdES B-B signatures"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust"]
-notes = "ETSI.CAdES.detached with signing-certificate-v2; pdfsig reports them valid"
-
-[[feature]]
-id = "sign.hash-algorithms"
-title = "SHA-256/384/512 hashes; no SHA-1 for new signatures"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.digital"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/crypto.rs::rsa_signatures_are_read_with_every_digestinfo_variant", "crates/sign/tests/crypto.rs::ecdsa_signatures_are_read_as_der_non_minimal_der_or_raw", "crates/sign/tests/crypto.rs::verifies_ecdsa_on_p521_and_the_brainpool_curves", "crates/sign/tests/crypto.rs::keys_of_unknown_algorithms_are_unsupported_not_wrong", "crates/sign/tests/crypto.rs::tolerated_encodings_are_noted_and_standard_ones_are_not", "crates/sign/tests/crypto.rs::weak_digests_say_so_in_words_that_are_pinned", "crates/sign/tests/crypto.rs::an_rsa_signature_value_not_below_the_modulus_is_refused", "crates/sign/tests/crypto.rs::hostile_rsa_signature_values_are_wrong_not_crashes", "crates/sign/tests/algorithms.rs::rsa_with_sha3_sha512_256_and_ripemd160", "crates/sign/tests/algorithms.rs::rsa_pss_with_its_declared_parameters", "crates/sign/tests/algorithms.rs::ed25519_signs_the_signed_attributes_themselves", "crates/sign/tests/algorithms.rs::ecdsa_on_brainpool_p512r1", "crates/sign/tests/algorithms.rs::brainpool_p512r1_keys_must_have_coordinates_reduced_modulo_p", "crates/sign/tests/algorithms.rs::only_weak_digests_leave_a_note_when_a_signature_validates", "crates/sign/tests/pdf.rs::validates_the_legacy_adbe_x509_rsa_sha1_format"]
-notes = "SHA-256, SHA-384 for P-384; SHA-1 refused for new signatures, validated in old ones. Validation also reads SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512, RIPEMD-160, RSA PKCS#1 with or without the DigestInfo NULL (a bare digest without any DigestInfo is refused), RSA-PSS with its declared MGF1 hash and salt length, ECDSA as DER/non-minimal DER/raw r||s on P-256, P-384, P-521 and brainpoolP256r1/P384r1/P512r1 (the last hand-written on crypto-bigint, checked against OpenSSL), Ed25519 (RFC 8419), and the legacy adbe.x509.rsa_sha1 format. An RSA value not below the modulus is refused (RFC 8017 5.2.2). SHA-1 and RIPEMD-160 validate but the signature details say the algorithm is weak (wording pinned by tests); tolerated encodings are listed in the details. An algorithm PdfCraft cannot check gives Unknown, not Invalid. Still missing: Ed448/SHAKE, DSA, brainpool curves other than P256/P384/P512, signing with the new digests"
-
-[[feature]]
-id = "sign.certify"
-title = "Certify a document (DocMDP)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.certify", "sign.certify_invisible"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"]
-notes = ""
-
-[[feature]]
-id = "sign.certify-permissions"
-title = "Allowed changes after certifying"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.certify"]
-tools = ["sign_document"]
-evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions"]
-notes = "DocMDP P=1/2/3 enforced when validating"
-
-[[feature]]
-id = "sign.field-mdp"
-title = "Field locks (FieldMDP)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.document-timestamp"
-title = "Add a document timestamp (RFC 3161)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/sign/tests/pdf.rs::a_document_timestamp_covers_the_file_and_validates"]
-notes = "timestamp_document appends a standalone /ETSI.RFC3161 signature dictionary over the whole file; validation discovers and verifies these (imprint, token signature, TSA cert validity) and reports the trusted time. Missing: automation tool and timestamp-server configuration (transport is caller-injected)."
-
-[[feature]]
-id = "sign.timestamp-servers"
-title = "Configure timestamp servers"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.pades-bt"
-title = "PAdES B-T (signature timestamp)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token"]
-notes = "Sign with an embedded RFC 3161 token (sign_with_timestamp): imprint and TSA signature verified at signing and validation time, trusted time reported. Missing: timestamp-server configuration and a fetcher (transport is caller-injected), the standalone document timestamp, automation tool."
-
-[[feature]]
-id = "sign.ltv"
-title = "Long-term validation (DSS/VRI)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/sign/tests/pdf.rs::embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"]
-notes = "dss::embed merges caller-supplied evidence into the catalog /DSS with /VRI entries per signature, deduplicating blobs, as a permitted incremental change; revocation.rs verifies the embedded CRLs and OCSP responses (signature, responder identity, CertID, window) and a verified revocation invalidates the signature. Missing: fetching evidence, automation tool."
-
-[[feature]]
-id = "sign.pades-blta"
-title = "PAdES B-LT and B-LTA"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/sign/tests/pdf.rs::sign_then_ltv_then_timestamp_makes_a_b_lta_file"]
-notes = "The B-LT/B-LTA chain works with caller-supplied evidence: sign, embed DSS (B-LT), then append a document timestamp over the result (B-LTA); earlier signatures stay valid with the store classified as a permitted change. Missing: OCSP/CRL verification of the evidence, re-timestamping automation."
-
-[[feature]]
-id = "sign.ocsp-crl"
-title = "Revocation checking (OCSP and CRL)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/sign/tests/crypto.rs::crls_verify_and_report_revocation", "crates/sign/tests/crypto.rs::ocsp_responses_verify_and_match_the_certificate", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"]
-notes = "RFC 5280 CRLs and RFC 6960 OCSP responses are parsed and verified (issuer/responder signature, delegated responder's OCSP-signing EKU, CertID hash match, validity window) and checked against the DSS during validation. Missing: network fetching of fresh evidence and the OS trust store."
-
-[[feature]]
-id = "sign.validate-all"
-title = "Validate all signatures"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.validate"]
-tools = ["sign_list"]
-evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/sign/tests/chain.rs::a_trusted_signer_whose_certificate_may_not_sign_is_not_valid"]
-notes = "The signer's key usage, extended key usage and critical extensions are checked; name constraints are not"
-
-[[feature]]
-id = "sign.signatures-panel"
-title = "Signatures panel with per-revision tree"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "partial"
-commands = ["sign.panel"]
-tools = ["sign_list"]
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
-notes = "per-signature tree with details and actions; no per-revision page outline yet"
-
-[[feature]]
-id = "sign.status-icons"
-title = "Signature status (valid, unknown, invalid)"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.panel"]
-tools = ["sign_list"]
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
-notes = ""
-
-[[feature]]
-id = "sign.validation-banner"
-title = "Signed/certified document message bar"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.panel"]
-tools = []
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"]
-notes = "signed/valid, problems, invalid and certified bars"
-
-[[feature]]
-id = "sign.signature-properties"
-title = "Signature properties (validity summary, signer, time)"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "partial"
-commands = ["sign.panel"]
-tools = ["sign_list"]
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
-notes = "validity summary, signer, issuer, time, algorithm in the panel; no separate properties dialog"
-
-[[feature]]
-id = "sign.certificate-viewer"
-title = "Certificate viewer"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.panel"]
-tools = ["sign_list"]
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
-notes = "Signatures panel ▸ Show certificate: the chain (root on top) and Summary, Details (serial, key, constraints, usage, SHA-1/SHA-256 digests) and Trust tabs; add to trusted certificates; export"
-
-[[feature]]
-id = "sign.trust-store"
-title = "Trusted certificates store"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.panel"]
-tools = ["sign_trust"]
-evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "trusted certificates persist with the app's settings"
-
-[[feature]]
-id = "sign.trust-import"
-title = "Import trusted certificates and set trust"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = []
-tools = ["sign_trust"]
-evidence = ["crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "import .cer/.pem/.der/.p12 through the tool; per-certificate trust settings not yet"
-
-[[feature]]
-id = "sign.eutl"
-title = "EU Trusted Lists"
-area = "H"
-tier = "P2"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.os-trust"
-title = "Trust the operating system's root certificates"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.verification-time"
-title = "Verification time (signing time, timestamp, now)"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token", "crates/sign/tests/pdf.rs::an_untrusted_timestamp_does_not_set_the_validation_time"]
-notes = "Validation prefers the verified RFC 3161 timestamp time over /M when checking certificate validity and revocation, but only when the TSA chains to the trust store; an untrusted token is reported as an unverified timestamp and its time is not used. Missing: an explicit verification-time option (verify at a chosen date / now) in the validation API."
-
-[[feature]]
-id = "sign.changes-after-signing"
-title = "List changes made after signing (MDP-aware diff)"
-area = "H"
-tier = "P0"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.panel"]
-tools = ["sign_list"]
-evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry"]
-notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787)"
-
-[[feature]]
-id = "sign.view-signed-version"
-title = "View the signed version"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = ["sign.panel"]
-tools = []
-evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
-notes = ""
-
-[[feature]]
-id = "sign.compare-signed-version"
-title = "Compare signed version with the current one"
-area = "H"
-tier = "P1"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.verify-on-open"
-title = "Verify signatures when a document opens"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "shipped"
-commands = []
-tools = ["sign_list"]
-evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"]
-notes = "validated when opened and after every edit"
-
-[[feature]]
-id = "sign.clear-signature"
-title = "Clear an unsaved signature; clear all signature fields"
-area = "H"
-tier = "P2"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.seed-values"
-title = "Signature seed values"
-area = "H"
-tier = "P2"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.preview-mode"
-title = "Preview document mode before signing"
-area = "H"
-tier = "P2"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.qualified-labels"
-title = "Show qualified signature/seal labels"
-area = "H"
-tier = "P2"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.web-signing"
-title = "Sign in the browser with a PKCS#12 file"
-area = "H"
-tier = "P1"
-milestone = "M9"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "sign.aatl"
-title = "Adobe Approved Trust List"
-area = "H"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe-hosted list; use OS trust stores and EU Trusted Lists"
-
-[[feature]]
-id = "sign.request-esignatures"
-title = "Request e-signatures (Acrobat Sign)"
-area = "H"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; pluggable provider hook only"
-
-[[feature]]
-id = "sign.cloud-signatures"
-title = "Cloud signature (CSC) digital IDs via Adobe"
-area = "H"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; a pluggable remote-signing KeyProvider may follow"
-
-# ==================================================================================================
-# I. Scan and OCR
-# ==================================================================================================
-
-[[feature]]
-id = "ocr.recognize-text"
-title = "Recognize text (OCR) in this file"
-area = "I"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["ocr.recognize"]
-tools = ["ocr_recognize", "ocr_status"]
-evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"]
-notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."
-
-[[feature]]
-id = "ocr.recognize-range"
-title = "Recognize text in the current page or a range"
-area = "I"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["ocr.recognize"]
-tools = ["ocr_recognize"]
-evidence = ["crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"]
-notes = "All pages, current page or a range."
-
-[[feature]]
-id = "ocr.recognize-multiple"
-title = "Recognize text in multiple files"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["ocr.recognize_batch"]
-tools = ["ocr_recognize_files"]
-evidence = ["crates/automation/tests/automation.rs::ocr_recognize_files_writes_searchable_copies", "crates/ui-egui/tests/ocr.rs::recognize_text_in_multiple_files_writes_searchable_copies"]
-notes = "Pick PDFs, then an output folder; copies are saved incrementally under the same names, in the background with progress."
-
-[[feature]]
-id = "ocr.ocr-languages"
-title = "OCR document language selection"
-area = "I"
-tier = "P0"
-milestone = "M10"
-status = "partial"
-commands = ["ocr.recognize"]
-tools = ["ocr_recognize"]
-evidence = ["crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"]
-notes = "Language choice exists, but the models read only the Latin alphabet without accents (English)."
-
-[[feature]]
-id = "ocr.searchable-image"
-title = "Searchable image output (invisible text layer)"
-area = "I"
-tier = "P0"
-milestone = "M10"
-status = "partial"
-commands = ["ocr.recognize"]
-tools = ["ocr_recognize"]
-evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable"]
-notes = "Invisible text (Tr 3) fitted to each word box; the image is not deskewed or downsampled (exact mode only)."
-
-[[feature]]
-id = "ocr.searchable-image-exact"
-title = "Searchable image (exact): leave the image untouched"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["ocr.recognize"]
-tools = ["ocr_recognize"]
-evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable"]
-notes = "The image is left untouched."
-
-[[feature]]
-id = "ocr.editable-text-output"
-title = "Editable text and images output"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "partial"
-commands = ["edit.edit_text"]
-tools = ["text_edit"]
-evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open"]
-notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Still missing: Acrobat's Recognize Text output that converts a whole page to editable text at once, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)."
-
-[[feature]]
-id = "ocr.ocr-downsample"
-title = "Downsample images during OCR"
-area = "I"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.skip-renderable-text"
-title = "Skip pages that already have text"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["ocr.recognize"]
-tools = ["ocr_recognize"]
-evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/engine/src/tests.rs::recognize_text_reads_a_picture_on_a_page_that_has_text", "crates/automation/tests/automation.rs::ocr_recognize_reads_pictures_on_pages_with_text", "crates/ui-egui/tests/ocr.rs::recognize_text_can_read_pictures_on_pages_with_text"]
-notes = "Pages that already have text are skipped and reported by default. The dialog's \"Also recognize pages that already have text\" (skip_text_pages false, also for multiple files) reads pictures on such pages (#1028); a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out and counted (already_on_page). Missing: recognising only the image areas (the whole page is still read, so it takes as long as a scan), and a word in a picture that sits under real text is treated as that text."
-
-[[feature]]
-id = "ocr.rotation-detection"
-title = "Detect and fix page rotation"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.deskew"
-title = "Deskew"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.despeckle"
-title = "Despeckle"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "partial"
-commands = ["ocr.enhance_camera"]
-tools = ["page_enhance_camera"]
-evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
-notes = "Camera images only: from clean-up level 60, isolated dark pixels are removed. Missing: for scanned pages and as an OCR option."
-
-[[feature]]
-id = "ocr.background-removal"
-title = "Background removal"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "partial"
-commands = ["ocr.enhance_camera"]
-tools = ["page_enhance_camera"]
-evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
-notes = "Camera images only: Enhance camera image's clean-up level (and page_enhance_camera level) evens out the paper's illumination per channel, so the background turns white and colour casts go; colour ink stays. Missing: the same for scanned pages and as an OCR option."
-
-[[feature]]
-id = "ocr.descreen"
-title = "Descreen"
-area = "I"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.remove-halo"
-title = "Remove halos"
-area = "I"
-tier = "P3"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.edge-shadow-removal"
-title = "Edge shadow removal"
-area = "I"
-tier = "P2"
-milestone = "M10"
-status = "partial"
-commands = ["ocr.enhance_camera"]
-tools = ["page_enhance_camera"]
-evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
-notes = "Camera images only: the clean-up level divides out a smooth illumination estimate, which removes shadows and darker edges on the straightened photo. Missing: for scanned pages (book gutters) and as an OCR option."
-
-[[feature]]
-id = "ocr.camera-perspective"
-title = "Enhance camera images (edges, perspective, contrast)"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "partial"
-commands = ["ocr.enhance_camera"]
-tools = ["page_enhance_camera"]
-evidence = ["crates/engine/src/tests.rs::enhance_camera_page_straightens_the_sheet", "crates/automation/tests/automation.rs::enhance_camera_image_through_tools", "crates/ui-egui/tests/camera.rs::dragging_a_corner_then_enhance_page_straightens_the_page", "crates/ui-egui/tests/camera.rs::after_enhance_page_crop_and_organize_take_over", "crates/engine/src/tests.rs::detect_camera_corners_finds_the_sheet_in_user_space", "crates/automation/tests/automation.rs::enhance_camera_image_finds_the_edges_by_itself"]
-notes = "Four corners (any order, a convex quadrilateral) are warped to an upright page at the picture's own resolution, in one undoable step; comments, links and bookmarks stay. Scan & OCR ▸ Enhance camera image shows four corner handles on the current page (Enhance page / Cancel / Esc), then offers rotate either way, Crop pages and Organize pages for the enhanced page; page_enhance_camera does the same headlessly. The corners start on the document's edges when they are found (straight lines with an edge along most of each side, refined by a line fit; tested on synthetic photos with text, wood grain, shadows, strong perspective and a 30° turn), else inset 5%. After Enhance page a clean-up slider (0–100, default 50) re-enhances from the straightened photo: even lighting, white paper, contrast, despeckle from 60 (camera_clean). Missing: turning the result upright (text orientation), rounded-corner cards keep their background in the corners, document types."
-
-[[feature]]
-id = "ocr.camera-document-type"
-title = "Camera image document types (whiteboard, card, form)"
-area = "I"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.correct-recognized-text"
-title = "Correct recognized text (low-confidence suspects)"
-area = "I"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.adaptive-compression"
-title = "Adaptive (MRC) compression of scans"
-area = "I"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.scan-pdfa"
-title = "Make scans PDF/A compliant"
-area = "I"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.ocr-engines"
-title = "Pluggable OCR engines (ocrs default; tesseract optional)"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.ocr-web"
-title = "OCR in the browser"
-area = "I"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.scanner-acquire"
-title = "Scan from TWAIN/WIA/ICA/SANE scanners"
-area = "I"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.scan-presets"
-title = "Scan presets"
-area = "I"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.scan-append"
-title = "Append scans to an existing PDF or split into files"
-area = "I"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "ocr.adobe-scan-sync"
-title = "Adobe Scan mobile capture sync"
-area = "I"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; import images from any source instead"
-
-# ==================================================================================================
-# J. Create and export
-# ==================================================================================================
-
-[[feature]]
-id = "create.from-images"
-title = "Create PDF from images (JPEG, PNG, TIFF, GIF, BMP, JPEG 2000)"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["create.images"]
-tools = ["doc_create"]
-evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::embedded_icc_profiles_tag_the_image_colour_space"]
-notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution). JPEG and PNG ICC profiles are kept as /ICCBased; TIFF profiles are not read yet"
-
-[[feature]]
-id = "create.from-multipage-tiff"
-title = "Multi-page TIFF to multi-page PDF"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["create.images", "create.file"]
-tools = ["doc_create"]
-evidence = ["crates/create/src/tests.rs::bmp_gif_and_multi_page_tiff_images", "crates/create/src/tests.rs::bigtiff_images_become_pages"]
-notes = "every page; 1-bit scans stay 1-bit; resolution from the TIFF; classic TIFF and BigTIFF"
-
-[[feature]]
-id = "create.from-text"
-title = "Create PDF from plain text"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["create.file"]
-tools = ["doc_create"]
-evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"]
-notes = "Helvetica 11 pt on US Letter; form feeds start pages"
-
-[[feature]]
-id = "create.from-markdown"
-title = "Create PDF from Markdown"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.from-clipboard"
-title = "Create PDF from the clipboard"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["create.clipboard"]
-tools = []
-evidence = ["crates/ui-egui/tests/create.rs::clipboard_images_and_text_become_new_pdfs"]
-notes = "an image (one page at its size) or text; headless tools read files instead"
-
-[[feature]]
-id = "create.from-screenshot"
-title = "Create PDF from a screen capture"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.blank"
-title = "Create a blank PDF"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["create.blank"]
-tools = ["doc_create"]
-evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"]
-notes = ""
-
-[[feature]]
-id = "create.from-html"
-title = "Create PDF from an HTML file"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.from-web-page"
-title = "Create PDF from a web page URL"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.from-office"
-title = "Create PDF from Office documents (LibreOffice sidecar)"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.create-multiple"
-title = "Convert many files to PDF in one run"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["create.multiple"]
-tools = ["doc_create_multiple"]
-evidence = ["crates/engine/src/tests.rs::mixed_files_convert_and_combine_in_order", "crates/engine/src/tests.rs::files_that_cannot_be_converted_are_refused_clearly", "crates/automation/tests/automation.rs::creating_from_multiple_files_through_tools", "crates/ui-egui/tests/create.rs::multiple_files_open_as_one_document_in_the_page_grid", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order", "crates/ui-egui/tests/editing.rs::save_pages_writes_what_the_grid_shows"]
-notes = "Create ▸ Multiple files: the picked PDFs, images (PNG, JPEG, JPEG 2000, TIFF, GIF, BMP) and .txt files open as one unsaved document in the page grid, in the order picked (a bookmark per file); there pages are removed, reordered, more files inserted at any gap (a + button, or dropped there), and Save pages writes what is shown. One PDF per file is offered by the doc_create_multiple tool only (mode separate), not in the app. Missing: Office, HTML and Markdown inputs, PDF Portfolio output, per-file image resolution choice, progress and cancel for large batches (conversion runs on the UI thread). The combined file has combine's limits: untagged, no page labels or document info carried over, and its per-file bookmarks are not updated when pages are later moved between files."
-
-[[feature]]
-id = "create.image-conversion-options"
-title = "Image compression options when converting"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "partial"
-commands = ["create.images"]
-tools = ["doc_create"]
-evidence = ["crates/create/src/tests.rs::image_resolution_override_changes_size_without_resampling", "crates/automation/tests/automation.rs::creating_images_with_dpi_through_tools", "crates/ui-egui/tests/create.rs::image_import_dialog_chooses_dpi_and_cancels"]
-notes = "Page sizing: embedded resolution (72 dpi fallback), fixed 72 dpi, or custom 1–1200 dpi; pixels are not resampled. Compression options are still absent. Windows image context menu opens the same chooser; other OS context-menu integration not yet."
-
-[[feature]]
-id = "create.pdf-printer"
-title = "Print to PDF from other applications"
-area = "J"
-tier = "P3"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.distiller"
-title = "PostScript/EPS to PDF (Distiller)"
-area = "J"
-tier = "P3"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "deferred: a PostScript interpreter is a large project"
-
-[[feature]]
-id = "create.job-options"
-title = "PDF settings presets (job options)"
-area = "J"
-tier = "P3"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-docx"
-title = "Export to Word (.docx)"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "partial"
-commands = ["export.docx"]
-tools = ["doc_export_office"]
-evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
-notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
-
-[[feature]]
-id = "create.export-rtf"
-title = "Export to RTF"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["export.rtf"]
-tools = ["doc_export_office"]
-evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
-notes = "Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out."
-
-[[feature]]
-id = "create.export-xlsx"
-title = "Export to Excel (.xlsx) with table detection"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-pptx"
-title = "Export to PowerPoint (.pptx)"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "partial"
-commands = ["export.pptx"]
-tools = ["doc_export_office"]
-evidence = [
- "crates/export/src/pptx.rs::pptx_is_a_presentationml_package",
- "crates/export/src/pptx.rs::pptx_slide_has_title_body_and_images_in_order",
- "crates/export/src/pptx.rs::pptx_text_colour_and_formatting",
- "crates/export/src/pptx.rs::pptx_empty_pages_produce_valid_slide",
- "crates/export/src/pptx.rs::pptx_slide_dimensions_and_clamping",
- "crates/export/src/pptx.rs::pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title",
- "crates/export/src/pptx.rs::pptx_opens_in_libreoffice",
- "crates/export/src/pptx.rs::pptx_package_parts_are_complete_and_consistent",
- "crates/export/src/pptx.rs::hostile_sizes_do_not_overflow_the_slide_position",
- "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format",
- "crates/export/src/lib.rs::text_colour_survives_every_format",
- "crates/engine/src/tests.rs::exporting_office_files_keeps_images",
- "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour",
- "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf",
- "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf",
-]
-notes = "PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead."
-
-[[feature]]
-id = "create.export-png"
-title = "Export pages as PNG"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["export.image"]
-tools = ["doc_export_images", "page_render"]
-evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/engine/src/tests.rs::export_images_and_text_follow_the_working_file", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
-notes = "PNG at 72–600 dpi, page ranges; includes unsaved edits; background thread with progress"
-
-[[feature]]
-id = "create.export-jpeg-tiff"
-title = "Export pages as JPEG or TIFF"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["export.image"]
-tools = ["doc_export_images"]
-evidence = ["crates/engine/src/tests.rs::pages_export_as_png_jpeg_and_tiff", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
-notes = "JPEG with quality, TIFF; flattened onto white"
-
-[[feature]]
-id = "create.export-jpeg2000"
-title = "Export pages as JPEG 2000"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-all-images"
-title = "Export all embedded images"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["export.all_images"]
-tools = ["doc_export_all_images"]
-evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text"]
-notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter"
-
-[[feature]]
-id = "create.export-html"
-title = "Export to HTML"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "partial"
-commands = ["export.html"]
-tools = ["doc_export_office"]
-evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
-notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts."
-
-[[feature]]
-id = "create.export-text"
-title = "Export to plain text"
-area = "J"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["export.text"]
-tools = ["doc_export_text", "text_extract"]
-evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
-notes = "plain reading-order text; accessible text (tags) comes with M12"
-
-[[feature]]
-id = "create.export-accessible-text"
-title = "Export accessible text (from tags)"
-area = "J"
-tier = "P1"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-xml"
-title = "Export to XML"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-svg"
-title = "Export to SVG"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-ps"
-title = "Export to PostScript"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-eps"
-title = "Export to EPS"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-ocr"
-title = "Run OCR automatically when exporting scans"
-area = "J"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.export-settings"
-title = "Per-format export settings"
-area = "J"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "create.text-extraction"
-title = "Extract text in reading order (columns, RTL, CJK)"
-area = "J"
-tier = "P0"
-milestone = "M2"
-status = "shipped"
-commands = []
-tools = ["text_extract"]
-evidence = ["crates/render/src/text.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text.rs::lines_words_and_search", "crates/automation/tests/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"]
-notes = ""
-
-[[feature]]
-id = "create.translate"
-title = "Translate documents"
-area = "J"
-tier = "P3"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "through an optional AiProvider; never Adobe's cloud"
-
-[[feature]]
-id = "create.cloud-office-conversion"
-title = "Cloud conversion from the macOS Office add-in"
-area = "J"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; LibreOffice sidecar instead"
-
-# ==================================================================================================
-# K. Optimize and standards
-# ==================================================================================================
-
-[[feature]]
-id = "optimize.reduce-file-size"
-title = "Reduce file size (one click)"
-area = "K"
-tier = "P0"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.reduce"]
-tools = ["doc_reduce"]
-evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"]
-notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel"
-
-[[feature]]
-id = "optimize.compatibility-level"
-title = "Make compatible with a chosen PDF version"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.optimizer"
-title = "Advanced optimization (PDF Optimizer)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"]
-notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit"
-evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"]
-notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels"
-
-[[feature]]
-id = "optimize.downsample-images"
-title = "Downsample colour, grey and mono images"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"]
-notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet"
-evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); monochrome not yet"
-
-[[feature]]
-id = "optimize.recompress-images"
-title = "Recompress images (JPEG, JPEG 2000, Flate)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens"]
-notes = "JPEG (quality steps) and ZIP; no JPEG 2000"
-
-[[feature]]
-id = "optimize.mono-compression"
-title = "Monochrome compression (JBIG2, CCITT G4)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.only-if-smaller"
-title = "Optimize images only if smaller"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled"]
-notes = "always on"
-
-[[feature]]
-id = "optimize.unembed-fonts"
-title = "Unembed fonts"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.flatten-transparency"
-title = "Flatten transparency"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.discard-javascript"
-title = "Discard JavaScript actions"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = "with links and actions (Remove Hidden Information)"
-
-[[feature]]
-id = "optimize.discard-thumbnails"
-title = "Discard embedded thumbnails"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-tags"
-title = "Discard document tags"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-bookmarks"
-title = "Discard bookmarks"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-search-index"
-title = "Discard embedded search index"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.discard-print-settings"
-title = "Discard embedded print settings"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-alternate-images"
-title = "Discard alternate images"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-form-actions"
-title = "Discard form submit, import and reset actions"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.flatten-form-fields"
-title = "Flatten form fields while optimizing"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-comments"
-title = "Discard comments, forms and multimedia"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-metadata"
-title = "Discard document information and metadata"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-attachments"
-title = "Discard file attachments"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-private-data"
-title = "Discard private application data (PieceInfo)"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = ""
-
-[[feature]]
-id = "optimize.discard-external-refs"
-title = "Discard external cross-references"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.discard-hidden-layers"
-title = "Discard hidden layers and flatten visible ones"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "partial"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
-notes = "hidden layer content removed; visible layers not flattened"
-
-[[feature]]
-id = "optimize.merge-image-fragments"
-title = "Detect and merge image fragments"
-area = "K"
-tier = "P3"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.lines-to-curves"
-title = "Convert smooth lines to curves"
-area = "K"
-tier = "P3"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.compress-structure"
-title = "Compress document structure (object streams)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["file.save_as"]
-tools = ["doc_save"]
-evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams"]
-notes = "on by default for full saves; no Optimizer option yet"
-
-[[feature]]
-id = "optimize.flate-unencoded"
-title = "Flate-encode unencoded streams; replace LZW with Flate"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up"]
-notes = "unencoded streams; LZW not yet re-encoded"
-
-[[feature]]
-id = "optimize.remove-invalid-links"
-title = "Remove invalid links, bookmarks and unreferenced destinations"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_optimize"]
-evidence = ["crates/optimize/tests/optimize.rs::invalid_links_and_unreferenced_destinations_go"]
-notes = "Clean Up: links to missing pages or names are removed; bookmarks keep their place (and children) but lose a broken destination; named destinations nothing refers to (links, bookmarks, actions anywhere) are removed from the name tree and the old /Dests; counts reported"
-
-[[feature]]
-id = "optimize.optimize-content"
-title = "Optimize page content streams"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.audit-space"
-title = "Audit space usage"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "shipped"
-commands = ["optimize.advanced"]
-tools = ["doc_audit_space"]
-evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/viewer.rs::space_audit_numbers_line_up_under_their_headers"]
-notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)"
-
-[[feature]]
-id = "optimize.optimizer-presets"
-title = "Optimizer presets"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.batch-optimize"
-title = "Optimize multiple files"
-area = "K"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight"
-title = "Preflight analysis"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-fix"
-title = "Preflight analyze and fix"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-profiles"
-title = "Built-in preflight profiles"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-single-checks"
-title = "Single checks"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-single-fixups"
-title = "Single fixups"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-custom-profiles"
-title = "Create and edit custom profiles"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-profile-exchange"
-title = "Import, export and lock profiles"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-results"
-title = "Preflight results tree with object details"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-snap-view"
-title = "Snap view of a flagged object"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-reports"
-title = "Preflight reports (PDF, XML, text)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-droplets"
-title = "Preflight droplets and batch preflight"
-area = "K"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.preflight-audit-trail"
-title = "Embed preflight audit trail"
-area = "K"
-tier = "P3"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.pdfa-validate"
-title = "Verify PDF/A compliance (1-4)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["standards.pdfa"]
-tools = ["pdfa_verify"]
-evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
-notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison."
-
-[[feature]]
-id = "optimize.pdfa-convert"
-title = "Save as PDF/A (1b, 2b, 3b, 4)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["standards.pdfa"]
-tools = ["pdfa_convert"]
-evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
-notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1."
-
-[[feature]]
-id = "optimize.pdfa-accessible-levels"
-title = "PDF/A conformance levels a and u"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.pdfx-validate"
-title = "Verify PDF/X compliance"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.pdfx-convert"
-title = "Save as PDF/X (1a, 3, 4)"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.output-intents"
-title = "Choose output intents and conditions"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["standards.pdfa"]
-tools = ["pdfa_convert", "pdfa_verify"]
-evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
-notes = "sRGB output intent added and listed; no choice of other conditions yet."
-
-[[feature]]
-id = "optimize.pdfe"
-title = "PDF/E"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.pdfvt"
-title = "PDF/VT"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.pdfua-validate"
-title = "Verify PDF/UA-1 and PDF/UA-2"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.standards-panel"
-title = "Standards panel showing declared conformance"
-area = "K"
-tier = "P1"
-milestone = "M11"
-status = "partial"
-commands = ["standards.pdfa"]
-tools = ["pdfa_verify"]
-evidence = ["crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
-notes = "Declared PDF/A, PDF/UA and output intents shown in the PDF/A dialog and returned by pdfa_verify."
-
-[[feature]]
-id = "optimize.remove-standards-id"
-title = "Remove PDF/A or PDF/X identification"
-area = "K"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.pdfx5-reference-xobjects"
-title = "Show PDF/X-5 reference XObjects"
-area = "K"
-tier = "P3"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "optimize.einvoice-pdfa3"
-title = "Embed e-invoice XML in PDF/A-3"
-area = "K"
-tier = "P3"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-# ==================================================================================================
-# L. Print and print production
-# ==================================================================================================
-
-[[feature]]
-id = "print.print-dialog"
-title = "Print dialog"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print", "printers"]
-evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"]
-notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog"
-
-[[feature]]
-id = "print.printer-selection"
-title = "Choose printer and properties"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["printers", "printer_options", "doc_print"]
-evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job"]
-notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't)"
-
-[[feature]]
-id = "print.copies-collate"
-title = "Copies and collation"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
-notes = "lp -n, collate option; on Windows the driver's Copies and Collate settings, as the job's"
-
-[[feature]]
-id = "print.page-range"
-title = "Print all, current page or a range (labels allowed)"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/automation.rs::printing_through_tools"]
-notes = "all, current, ranges with labels and open ends"
-
-[[feature]]
-id = "print.odd-even-reverse"
-title = "Odd/even pages and reverse order"
-area = "L"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::page_selection"]
-notes = ""
-
-[[feature]]
-id = "print.scaling"
-title = "Fit, actual size, shrink oversized, custom scale"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"]
-notes = "fit, actual size, shrink oversized, custom %; ARCH A/B/C/D/E1/E, ANSI C/D/E, ISO A0/A1/A2 and A6 presets; Match page at 100% matches the current page to a standard sheet. PDF scale shows the previewed source-to-sheet transform, not calibrated physical output; driver substitution/scaling remains possible"
-
-[[feature]]
-id = "print.paper-source-by-size"
-title = "Choose paper source by PDF page size"
-area = "L"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.duplex"
-title = "Print on both sides"
-area = "L"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
-notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting"
-
-[[feature]]
-id = "print.n-up"
-title = "Multiple pages per sheet"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"]
-notes = "2/4/6/9/16, four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused."
-
-[[feature]]
-id = "print.booklet"
-title = "Booklet printing"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding"]
-notes = "both/front/back, left/right binding"
-
-[[feature]]
-id = "print.poster"
-title = "Poster / tiled printing with overlap and cut marks"
-area = "L"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::posters_tile_with_overlap", "crates/automation/tests/automation.rs::printing_through_tools"]
-notes = "tile scale, overlap, cut marks (labels pending)"
-
-[[feature]]
-id = "print.orientation"
-title = "Auto portrait/landscape"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"]
-notes = "auto portrait/landscape"
-
-[[feature]]
-id = "print.comments-and-forms"
-title = "Print document, markups, stamps or form fields only"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms"]
-notes = "Print flag honoured"
-
-[[feature]]
-id = "print.print-with-comment-summary"
-title = "Print with a comments summary"
-area = "L"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.print-preview"
-title = "Print preview"
-area = "L"
-tier = "P0"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = []
-evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf"]
-notes = "live sheet preview with sheet navigation"
-
-[[feature]]
-id = "print.grayscale"
-title = "Print in grayscale"
-area = "L"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"]
-notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too. Still missing against Acrobat: a grey conversion of the vector pages without rendering them."
-
-[[feature]]
-id = "print.print-as-image"
-title = "Print as image"
-area = "L"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"]
-notes = "The Print dialog's Advanced section (folded unless on): Print as image at 150, 300 or 600 dpi; doc_print as_image and dpi (72–600). The pages used are rendered once each the way the viewer shows them (comments left out when the print leaves them out), composed over white, made into an image-only document and imposed on the paper like the pages themselves. The print is no longer searchable text. Still missing against Acrobat: per-printer defaults for it."
-
-[[feature]]
-id = "print.page-setup"
-title = "Page setup"
-area = "L"
-tier = "P1"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.print-ready-pdf"
-title = "Produce a print-ready imposed PDF"
-area = "L"
-tier = "P1"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = ["doc_print"]
-evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"]
-notes = "Save as PDF from the dialog"
-
-[[feature]]
-id = "print.print-pages-from-thumbnails"
-title = "Print selected pages from thumbnails"
-area = "L"
-tier = "P2"
-milestone = "M10"
-status = "shipped"
-commands = ["print.dialog"]
-tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::print_shortcut_offers_the_pages_picked_in_the_pages_panel", "crates/ui-egui/tests/editing.rs::pages_panel_command_click_picks_pages_without_moving_the_document", "crates/ui-egui/tests/editing.rs::pages_panel_shift_click_picks_a_range_and_a_plain_click_starts_over", "crates/ui-egui/tests/print.rs::print_dialog_prints_the_selected_pages", "crates/print/src/tests.rs::page_selection"]
-notes = "Ctrl/⌘-click and ⇧-click pick thumbnails in the Pages panel (and the Organize grid); Print opens on \"Selected pages\" and prints them in page order. Missing: the thumbnails' own context-menu Print command, picking by dragging a marquee, arrow-key selection, and a printer on Windows (Save as PDF only there, see print.printer-selection)."
-
-[[feature]]
-id = "print.postscript-output"
-title = "PostScript Level 3 output"
-area = "L"
-tier = "P2"
-milestone = "M10"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.output-preview"
-title = "Output preview"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.separations"
-title = "Separations preview with area coverage"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.total-area-coverage"
-title = "Total area coverage warning"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.color-warnings"
-title = "Overprint and rich-black warnings"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.object-inspector"
-title = "Object inspector"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.simulate-paper-ink"
-title = "Simulate paper colour and black ink"
-area = "L"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.overprint-preview"
-title = "Overprint preview"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.convert-colors"
-title = "Convert colours"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.ink-manager"
-title = "Ink manager"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.ink-aliases"
-title = "Ink aliases and spot-to-process"
-area = "L"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.printer-marks"
-title = "Add printer marks (crop, bleed, registration, colour bars)"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.fix-hairlines"
-title = "Fix hairlines"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.flattener-preview"
-title = "Flattener preview"
-area = "L"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.color-management"
-title = "Colour management settings (working spaces, intents)"
-area = "L"
-tier = "P1"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.advanced-print-separations"
-title = "Print separations and in-RIP separations"
-area = "L"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.print-marks-bleeds"
-title = "Marks and bleeds when printing"
-area = "L"
-tier = "P2"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.trap-presets"
-title = "Trap presets (stored as data)"
-area = "L"
-tier = "P3"
-milestone = "M11"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "print.jdf"
-title = "JDF job definitions (read-only)"
-area = "L"
-tier = "P3"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-# ==================================================================================================
-# M. Accessibility
-# ==================================================================================================
-
-[[feature]]
-id = "a11y.app-screen-reader"
-title = "Screen-reader support for the app (AccessKit)"
-area = "M"
-tier = "P0"
-milestone = "M3"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id"]
-notes = "AccessKit labels on all custom widgets; full audit in M14.3"
-
-[[feature]]
-id = "a11y.keyboard-only"
-title = "Full keyboard operation of the app"
-area = "M"
-tier = "P0"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.checker"
-title = "Accessibility checker (full check)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/a11y/src/tests.rs::structure_findings_identify_indirect_elements_without_guessing_inline_objects", "crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::annotation_findings_only_locate_valid_rectangles", "crates/automation/tests/automation.rs::accessibility_check_locates_field_description_on_page", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/ui-egui/tests/accessibility.rs::checker_shows_a_figure_finding_on_its_page", "crates/ui-egui/tests/accessibility.rs::checker_summary_does_not_hide_manual_review", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "full check: options dialog (report, page range, 32 rules by category, 31 on by default), results panel by category with Passed / Failed / Needs manual check / Skipped and findings that go to their page. A zero-failure check reports manual or skipped rules rather than claiming no issues. Locatable figure, annotation and form-widget findings flash their bounds on the page; accessibility_check exposes those bounds in PDF user space. Other findings navigate to their page. Structure findings expose the indirect tag object number and generation through accessibility_check, when available, for precise follow-up in the Tags Panel."
-
-[[feature]]
-id = "a11y.checker-report"
-title = "Accessibility report"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.report"]
-tools = ["accessibility_report", "accessibility_review_record", "accessibility_review_html"]
-evidence = ["crates/a11y/src/tests.rs::rules_have_ids_and_the_report_lists_them", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them", "crates/automation/src/a11y.rs::document_names_are_escaped_in_review_html"]
-notes = "HTML checker report: summary counts and a table per category with descriptions and findings. The headline shows manual or skipped rules when there are no automated failures. Headless comparison of two open versions exports the same before-and-after evidence as JSON or accessible, self-contained HTML with hashes, rule changes, and remaining review items."
-
-[[feature]]
-id = "a11y.checker-fix"
-title = "Fix, skip, explain and re-check rules"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_fix", "accessibility_review_record"]
-evidence = ["crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them"]
-notes = "right-click a rule: Fix (language via Document Properties, title, tab order), Skip Rule (kept on Check Again), Explain, Check Again, Show Report, Options. The panel compares outcome counts and changed rules with the previous check of the same document and options; the headless review record compares two open revisions."
-
-[[feature]]
-id = "a11y.rule-permission-flag"
-title = "Rule: accessibility permission flag"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules"]
-notes = "rule permission-flag: the file's /P bit 10 whichever password opened it"
-
-[[feature]]
-id = "a11y.rule-image-only"
-title = "Rule: image-only PDF"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"]
-notes = "rule image-only: images and no text on the checked pages"
-
-[[feature]]
-id = "a11y.rule-tagged-pdf"
-title = "Rule: tagged PDF"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules"]
-notes = "rule tagged-pdf: StructTreeRoot and MarkInfo Marked true"
-
-[[feature]]
-id = "a11y.rule-logical-order"
-title = "Rule: logical reading order (manual)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
-notes = "rule logical-reading-order: needs manual check"
-
-[[feature]]
-id = "a11y.rule-primary-language"
-title = "Rule: primary language"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "rule primary-language: catalog /Lang; fix via Document Properties or accessibility_fix"
-
-[[feature]]
-id = "a11y.rule-title"
-title = "Rule: title shown in the window"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "rule title: Info Title and DisplayDocTitle; Fix sets both"
-
-[[feature]]
-id = "a11y.rule-bookmarks"
-title = "Rule: bookmarks in long documents"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"]
-notes = "rule bookmarks: more than 20 pages need bookmarks"
-
-[[feature]]
-id = "a11y.rule-color-contrast"
-title = "Rule: colour contrast (manual)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "rule color-contrast: needs manual check; off by default as in Acrobat"
-
-[[feature]]
-id = "a11y.rule-tagged-content"
-title = "Rule: all content tagged or artifact"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
-notes = "rule tagged-content: painting outside MCID or Artifact marked content, through form XObjects; counted per page"
-
-[[feature]]
-id = "a11y.rule-tagged-annotations"
-title = "Rule: tagged annotations"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
-notes = "rule tagged-annotations: annotations other than popups, widgets and multimedia need /StructParent"
-
-[[feature]]
-id = "a11y.rule-tab-order"
-title = "Rule: tab order follows structure"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
-notes = "rule tab-order: pages with annotations need /Tabs /S; Fix sets it on every page"
-
-[[feature]]
-id = "a11y.rule-character-encoding"
-title = "Rule: reliable character encoding"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"]
-notes = "rule character-encoding: ToUnicode, a standard encoding, or a predefined CJK collection; each font once"
-
-[[feature]]
-id = "a11y.rule-tagged-multimedia"
-title = "Rule: tagged multimedia"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
-notes = "rule tagged-multimedia: Screen, Movie, Sound, RichMedia and 3D annotations need /StructParent"
-
-[[feature]]
-id = "a11y.rule-screen-flicker"
-title = "Rule: screen flicker (manual)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
-notes = "rule screen-flicker: needs manual check"
-
-[[feature]]
-id = "a11y.rule-scripts"
-title = "Rule: scripts (manual)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "rule scripts: needs manual check"
-
-[[feature]]
-id = "a11y.rule-timed-responses"
-title = "Rule: timed responses (manual)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
-notes = "rule timed-responses: needs manual check"
-
-[[feature]]
-id = "a11y.rule-navigation-links"
-title = "Rule: navigation links (manual)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
-notes = "rule navigation-links: needs manual check"
-
-[[feature]]
-id = "a11y.rule-tagged-fields"
-title = "Rule: tagged form fields"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
-notes = "rule tagged-form-fields: widgets need /StructParent"
-
-[[feature]]
-id = "a11y.rule-field-descriptions"
-title = "Rule: field descriptions"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
-notes = "rule field-descriptions: fields need a tooltip (/TU)"
-
-[[feature]]
-id = "a11y.rule-figure-alt"
-title = "Rule: figures have alternate text"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule figures-alt-text: Figure elements need Alt or ActualText (or an ancestor's)"
-
-[[feature]]
-id = "a11y.rule-nested-alt"
-title = "Rule: no nested alternate text"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule nested-alt-text: Alt inside an element with Alt"
-
-[[feature]]
-id = "a11y.rule-alt-associated"
-title = "Rule: alternate text associated with content"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule alt-text-associated: Alt on an element with no content beneath it"
-
-[[feature]]
-id = "a11y.rule-alt-hides-annot"
-title = "Rule: alternate text does not hide annotations"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule alt-text-hides-annotation: Alt on an element containing an OBJR"
-
-[[feature]]
-id = "a11y.rule-other-alt"
-title = "Rule: other elements have alternate text"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule other-elements-alt-text: Formula elements need Alt"
-
-[[feature]]
-id = "a11y.rule-table-rows"
-title = "Rule: table rows"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule table-rows: TR parent is Table, THead, TBody or TFoot"
-
-[[feature]]
-id = "a11y.rule-table-cells"
-title = "Rule: TH and TD in rows"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule table-cells: TH/TD parent is TR"
-
-[[feature]]
-id = "a11y.rule-table-headers"
-title = "Rule: tables have headers"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule table-headers: at least one TH"
-
-[[feature]]
-id = "a11y.rule-table-regularity"
-title = "Rule: table regularity"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::row_and_column_spans_keep_a_table_regular"]
-notes = "rule table-regularity: equal row widths counting ColSpan and RowSpan"
-
-[[feature]]
-id = "a11y.rule-table-summary"
-title = "Rule: table summary"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule table-summary: Summary table attribute"
-
-[[feature]]
-id = "a11y.rule-list-items"
-title = "Rule: list items"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule list-items: LI parent is L"
-
-[[feature]]
-id = "a11y.rule-lbl-lbody"
-title = "Rule: Lbl and LBody"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule lbl-lbody: Lbl/LBody parent is LI (TOCI and notes allowed)"
-
-[[feature]]
-id = "a11y.rule-heading-nesting"
-title = "Rule: heading nesting"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.check"]
-tools = ["accessibility_check"]
-evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
-notes = "rule heading-nesting: H1–H6 never skip a level going down; the first is H1"
-
-[[feature]]
-id = "a11y.autotag"
-title = "Automatically tag a document"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.autotag"]
-tools = ["accessibility_autotag_preview", "accessibility_autotag_apply"]
-evidence = ["crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/ui-egui/tests/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"]
-notes = "Auto-tag writes real marked content, a Document structure element, P tags and a parent tree for text-only PDFs, with undo and Tags-panel review. It refuses mixed content, forms, annotations and existing tags. The preview separately proposes headings. Images, tables, lists, links, fields and headings are not classified or tagged by Apply."
-
-[[feature]]
-id = "a11y.autotag-fields"
-title = "Autotag form fields"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.autotag_fields"]
-tools = ["accessibility_autotag_form_fields"]
-evidence = ["crates/a11y/src/tests.rs::autotag_form_fields_writes_widget_structure_and_parent_tree", "crates/automation/tests/automation.rs::autotag_form_fields_writes_reviewable_widget_tags"]
-notes = "For an untagged AcroForm, writes Form structure elements, OBJR references, widget StructParent keys and ParentTree entries. An existing tree is extended when its root points directly to a Document element and its ParentTree uses a direct Nums array; other layouts are refused before any edit. A widget listed on several pages is tagged once. Text, figures, links and other content remain untagged."
-
-[[feature]]
-id = "a11y.reading-order-tool"
-title = "Reading order tool"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = []
-tools = ["accessibility_move_tag"]
-evidence = ["crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "The headless tool can move a reachable indirect tag before or after a direct sibling in their parent's /K array as one undoable edit. It preserves page content and rejects ambiguous children. The visual Reading Order tool, inline tags, arbitrary retagging, and moving across parents remain planned."
-
-[[feature]]
-id = "a11y.tags-panel"
-title = "Tags panel (new, delete, find, change to artifact)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.tags"]
-tools = ["accessibility_tags"]
-evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag", "crates/automation/tests/automation.rs::structure_tags_are_listed_a_page_at_a_time"]
-notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. accessibility_tags lists them a page at a time (offset, limit, next) and can keep only some types, pages or depths. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982."
-
-[[feature]]
-id = "a11y.tag-properties"
-title = "Tag properties (type, title, actual text, alternate text, language)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.tags"]
-tools = ["accessibility_tags", "accessibility_set_tag_property"]
-evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/a11y/src/tests.rs::tag_hierarchy_keeps_indirect_parent_through_inline_tags", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
-notes = "The engine, headless tools and right-side editor list reachable indirect tags with parent and depth and edit type, title, actual text, alternate text and language with undo. Inline tag dictionaries and structural operations remain planned with the Tags Panel (#982)."
-
-[[feature]]
-id = "a11y.role-map"
-title = "Role map editor"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.class-map"
-title = "Class map editor"
-area = "M"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.order-panel"
-title = "Order panel"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.tags"]
-tools = ["accessibility_move_tag"]
-evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_moves_a_tag_within_its_siblings", "crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "The Tags panel moves a selected reachable indirect tag earlier or later among direct siblings, preserving page appearance and making one undoable reading-order edit. It does not yet show page-content order visually, edit direct tag dictionaries, move across parents, or reorder arbitrary content."
-
-[[feature]]
-id = "a11y.content-panel"
-title = "Content panel"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.tags"]
-tools = ["accessibility_tag_content"]
-evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
-notes = "Selecting a tag in the Tags panel lists its direct marked-content references with MCID, page and extracted text, and selecting an item navigates to the page. The content tree is presented within the Tags panel; editing content objects and arbitrary inline structure nodes remain planned."
-
-[[feature]]
-id = "a11y.create-tag-from-selection"
-title = "Create a tag from a selection"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.artifact-marking"
-title = "Mark content as artifact"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.tags"]
-tools = ["accessibility_mark_tag_artifact"]
-evidence = ["crates/a11y/src/tests.rs::marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity", "crates/automation/tests/automation.rs::marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved", "crates/ui-egui/tests/accessibility.rs::tags_panel_marks_a_leaf_tag_as_an_artifact"]
-notes = "Tags panel and automation can retire a reachable indirect leaf tag that owns direct MCIDs: its visible page content remains but becomes /Artifact and the parent-tree references are cleared. Tags with structural children, non-MCID content, missing pages or ambiguous parents are refused. Arbitrary painted-content selection, direct structure dictionaries and removing whole tagged subtrees remain unsupported."
-
-[[feature]]
-id = "a11y.find-untagged"
-title = "Find unmarked content, comments and links"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "partial"
-commands = ["a11y.find_untagged"]
-tools = ["accessibility_find_untagged"]
-evidence = ["crates/automation/tests/automation.rs::finding_untagged_content_limits_results_to_content_annotations_and_widgets", "crates/ui-egui/tests/accessibility.rs::finding_untagged_content_shows_the_focused_accessibility_results"]
-notes = "Lists findings for untagged painted page content, non-widget annotations and form widgets, optionally on selected pages. It reuses the checker and does not identify untagged table structure, links already represented by an annotation, or structure-tree elements that lack content."
-
-[[feature]]
-id = "a11y.table-editor"
-title = "Table editor (headers, scope, spans)"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.alt-text-workflow"
-title = "Set alternate text figure by figure (with decorative option)"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["a11y.alt_text"]
-tools = ["accessibility_figures", "accessibility_set_alt"]
-evidence = ["crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::setting_alt_text_and_marking_decorative", "crates/ui-egui/tests/accessibility.rs::setting_alternate_text_figure_by_figure", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
-notes = "Set Alternate Text dialog: each tagged figure (role map honoured) with a preview of where it is drawn, its alternate text, Decorative figure (content becomes /Artifact, element leaves the tags and parent tree), Previous/Next, Save & Close as one undo step"
-
-[[feature]]
-id = "a11y.alt-text-suggestions"
-title = "Suggest alternate text via an AI provider"
-area = "M"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "optional provider; off by default"
-
-[[feature]]
-id = "a11y.set-language"
-title = "Set document language"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_initial_view"]
-evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open"]
-notes = "Document Properties ▸ Initial View ▸ Language (catalog /Lang)"
-
-[[feature]]
-id = "a11y.set-title-display"
-title = "Show the document title in the window"
-area = "M"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["file.properties"]
-tools = ["doc_initial_view"]
-evidence = ["crates/automation/tests/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/viewer.rs::tab_and_window_show_the_document_title_when_asked"]
-notes = "Initial View ▸ Show: Document Title (/DisplayDocTitle); the tab and window then show the title"
-
-[[feature]]
-id = "a11y.make-accessible-action"
-title = "Make Accessible guided action"
-area = "M"
-tier = "P1"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "Headless review sidecar tools save and reopen human decisions against the exact working PDF SHA-256. The guided GUI action and step-by-step remediation are still missing."
-
-[[feature]]
-id = "a11y.setup-assistant"
-title = "Accessibility setup assistant"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "Review decisions can be saved and reopened through accessibility_review_save and accessibility_review_load. A GUI assistant is still missing."
-
-[[feature]]
-id = "a11y.read-aloud"
-title = "Read out loud (platform TTS)"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.read-aloud-options"
-title = "Read-aloud voice, pitch and speed"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.replace-page-colors"
-title = "Replace page colours (high contrast)"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "shipped"
-commands = []
-tools = ["view_reading_colors"]
-evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"]
-notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)."
-
-[[feature]]
-id = "a11y.reflow-tagged"
-title = "Reflow tagged content"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "a11y.keyboard-selection-cursor"
-title = "Always show the keyboard selection cursor"
-area = "M"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-# ==================================================================================================
-# N. Compare, automation, AI, preferences, misc
-# ==================================================================================================
-
-[[feature]]
-id = "misc.compare-text"
-title = "Compare files: text differences"
-area = "N"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["doc.compare"]
-tools = ["doc_compare", "doc_compare_mark"]
-evidence = ["crates/compare/src/lib.rs::insertions_deletions_and_replacements", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"]
-notes = "Word-level Myers diff with patience anchors for long files; replaced/inserted/deleted shaded on the new file, listed in the Compare panel, or added as comments."
-
-[[feature]]
-id = "misc.compare-visual"
-title = "Compare files: visual differences"
-area = "N"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["doc.compare"]
-tools = ["doc_compare"]
-evidence = ["crates/compare/src/lib.rs::finds_changed_regions", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools"]
-notes = "Pages rendered at 72 dpi and compared page by page; differing regions shaded orange where no text change explains them."
-
-[[feature]]
-id = "misc.compare-side-by-side"
-title = "Side-by-side compare view with synchronised scrolling"
-area = "N"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["doc.compare"]
-tools = []
-evidence = ["crates/ui-egui/tests/compare.rs::compare_two_versions"]
-notes = "After Compare files the document area splits (when the old version is open in a tab and the window is at least 1000 px wide; the Compare panel's Side by side / New file only toggles it): the old file on the left, the new on the right, each a page view of its own with its shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half the pointer is over leads: the other takes its zoom, fit, layout, rotation and scroll offset; a change clicked in the panel jumps both halves to their side of it. Clear ends it. Still missing against Acrobat: scrolling by matching content rather than by offset (pages of different sizes drift), and the comparison kept when the old file's tab closes."
-
-[[feature]]
-id = "misc.compare-report"
-title = "Compare summary report as PDF"
-area = "N"
-tier = "P1"
-milestone = "M12"
-status = "shipped"
-commands = ["doc.compare"]
-tools = ["doc_compare_report"]
-evidence = ["crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"]
-notes = "Summary report as a new PDF (counts and every change with pages)."
-
-[[feature]]
-id = "misc.compare-filters"
-title = "Filter changes by type (text, images, formatting)"
-area = "N"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.compare-page-ranges"
-title = "Compare page ranges"
-area = "N"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.compare-scanned"
-title = "Compare scanned documents via OCR"
-area = "N"
-tier = "P2"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.action-wizard-run"
-title = "Run guided actions on files"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "shipped"
-commands = ["actions.wizard"]
-tools = ["action_list", "action_run"]
-evidence = ["crates/ui-egui/tests/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
-notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress."
-
-[[feature]]
-id = "misc.action-wizard-create"
-title = "Create and edit actions"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "shipped"
-commands = ["actions.wizard"]
-tools = ["action_run"]
-evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
-notes = "New/Edit/Delete actions with 12 step kinds (OCR, reduce size, remove hidden information, sanitize, flatten, watermark, header, footer, title, detect fields, JavaScript); remembered between runs."
-
-[[feature]]
-id = "misc.action-wizard-manage"
-title = "Import, export, copy and delete actions"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.action-prepare-distribution"
-title = "Built-in action: prepare for distribution"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "shipped"
-commands = ["actions.distribution"]
-tools = ["action_run"]
-evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
-notes = "Remove hidden information, flatten comments and fields."
-
-[[feature]]
-id = "misc.action-optimize-scans"
-title = "Built-in action: optimize scanned documents"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "shipped"
-commands = ["actions.optimize_scans"]
-tools = ["action_run"]
-evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools"]
-notes = "Recognize text, then reduce file size."
-
-[[feature]]
-id = "misc.action-archive"
-title = "Built-in action: archive documents"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.action-publish-web"
-title = "Built-in action: prepare for web publishing"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.batch-processing"
-title = "Batch processing over files and folders"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "partial"
-commands = ["actions.wizard", "ocr.recognize_batch"]
-tools = ["action_run", "ocr_recognize_files", "doc_batch"]
-evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions", "crates/automation/tests/batch.rs::batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs", "apps/pdfcraft-cli/tests/batch.rs::cli_batch_reports_partial_failure_and_continues_into_subfolders"]
-notes = "Actions and OCR run over chosen files. CLI/MCP doc_batch adds bounded recursive folders for convert/ocr/optimize recipes, retained relative paths, collision-safe outputs and per-file reports. GUI folder selection, generic action recipes, scheduling and resume remain absent. See docs/cli-batch.md."
-
-[[feature]]
-id = "misc.action-prompt-steps"
-title = "Prompt-user and instruction steps"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.action-javascript-step"
-title = "Execute JavaScript step"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.custom-commands"
-title = "Custom commands"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.custom-tools"
-title = "Custom tool sets"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.command-registry"
-title = "One command registry for menus, shortcuts, palette and automation"
-area = "N"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = ["command_list"]
-evidence = ["crates/engine/src/commands.rs::ids_and_shortcuts_are_unique", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"]
-notes = ""
-
-[[feature]]
-id = "misc.disabled-command-reasons"
-title = "Disabled commands explain why"
-area = "N"
-tier = "P1"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::disabled_commands_explain_themselves"]
-notes = ""
-
-[[feature]]
-id = "misc.cli-inspect"
-title = "CLI: inspect, render and extract text"
-area = "N"
-tier = "P0"
-milestone = "M2"
-status = "shipped"
-commands = []
-tools = ["doc_info", "page_render", "text_extract"]
-evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find"]
-notes = ""
-
-[[feature]]
-id = "misc.cli-edit"
-title = "CLI: edit, combine, extract, split"
-area = "N"
-tier = "P0"
-milestone = "M4"
-status = "shipped"
-commands = []
-tools = ["page_rotate", "page_delete", "doc_combine", "page_extract", "doc_split"]
-evidence = ["crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip"]
-notes = ""
-
-[[feature]]
-id = "misc.cli-run"
-title = "CLI run: call any automation tool or a JSON script"
-area = "N"
-tier = "P0"
-milestone = "M13"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed", "apps/pdfcraft-cli/tests/run_report.rs::a_failing_step_is_named_and_the_rest_are_skipped"]
-notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors. `run --script --report` prints one JSON document with a status per step (#1891); without it the output is unchanged."
-
-[[feature]]
-id = "misc.cli-completeness"
-title = "CLI covers every shipped command"
-area = "N"
-tier = "P0"
-milestone = "M13"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/automation/tests/automation.rs::command_list_reports_enablement_and_tools"]
-notes = "tools grow as features land"
-
-[[feature]]
-id = "misc.automation-tools"
-title = "Headless JSON-Schema tool table"
-area = "N"
-tier = "P0"
-milestone = "M13"
-status = "shipped"
-commands = []
-tools = ["command_list"]
-evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::errors_are_specific_and_safe"]
-notes = ""
-
-[[feature]]
-id = "misc.command-list-tool"
-title = "List commands with enablement and their tools"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "shipped"
-commands = []
-tools = ["command_list"]
-evidence = ["crates/automation/tests/automation.rs::command_list_reports_enablement_and_tools"]
-notes = ""
-
-[[feature]]
-id = "misc.mcp-server"
-title = "Opt-in MCP server over stdio"
-area = "N"
-tier = "P0"
-milestone = "M13"
-status = "shipped"
-commands = []
-tools = ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"]
-evidence = ["crates/automation/tests/automation.rs::mcp_session_over_stdio", "crates/automation/tests/automation.rs::mcp_errors", "crates/automation/tests/automation.rs::conventions_core_tools_preserve_root_and_batch_errors", "crates/automation/tests/automation.rs::conventions_annotations_and_strict_keys_in_both_modes"]
-notes = ""
-
-[[feature]]
-id = "misc.automation-root-confinement"
-title = "Confine automation file access to a root directory"
-area = "N"
-tier = "P0"
-milestone = "M13"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/automation/tests/automation.rs::errors_are_specific_and_safe", "crates/automation/tests/automation.rs::root_refusals_do_not_reveal_what_exists_outside", "apps/pdfcraft-cli/tests/run_root.rs::script_image_output_is_confined_to_the_root", "crates/automation/tests/automation.rs::file_names_from_documents_stay_in_the_output_folder"]
-notes = "One refusal for every path outside the root, existing or not (#136). `run --script` images (`out`) are confined too (#137). Check-then-write, not openat-style: a process racing the tool on the same disk could swap a folder for a link between the check and the write."
-
-[[feature]]
-id = "misc.mcp-resources"
-title = "MCP resources for page images and text"
-area = "N"
-tier = "P1"
-milestone = "M13"
-status = "shipped"
-commands = []
-tools = ["doc_info", "text_extract", "page_render"]
-evidence = ["crates/automation/tests/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse", "crates/automation/tests/automation.rs::conventions_resources"]
-notes = "resources/list, resources/templates/list and resources/read: pdfcraft://doc/{doc}/info (JSON), /text, /page/{page}/text, /page/{page}/image?dpi= (PNG)"
-
-[[feature]]
-id = "misc.ui-control-channel"
-title = "UI control channel (inspect, click, type, keys, commands)"
-area = "N"
-tier = "P0"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/control.rs::commands_keys_and_typing", "crates/ui-egui/tests/control.rs::loopback_transport_requires_the_token"]
-notes = ""
-
-[[feature]]
-id = "misc.ui-screenshots"
-title = "Screenshots of the window or a region via the control channel"
-area = "N"
-tier = "P1"
-milestone = "M3"
-status = "shipped"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/tests/control.rs::screenshots_of_window_and_region"]
-notes = ""
-
-[[feature]]
-id = "misc.ai-provider"
-title = "AI provider interface (local model or own endpoint), off by default"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.ai-summary"
-title = "Summarize a document"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.ai-ask"
-title = "Ask questions with citations to pages"
-area = "N"
-tier = "P2"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.ai-conversational-edit"
-title = "Natural-language editing through registry commands"
-area = "N"
-tier = "P3"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.ai-presentation"
-title = "Generate a presentation from documents"
-area = "N"
-tier = "P3"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.ai-podcast"
-title = "Generate an audio overview"
-area = "N"
-tier = "P3"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.ai-charts"
-title = "Charts from document data"
-area = "N"
-tier = "P3"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.contract-analysis"
-title = "Contract key terms and multi-contract compare"
-area = "N"
-tier = "P3"
-milestone = "M13"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.adobe-ai-assistant"
-title = "Adobe AI Assistant"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; replaced by the optional local/BYO AiProvider"
-
-[[feature]]
-id = "misc.pdf-spaces"
-title = "PDF Spaces"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; no local equivalent planned"
-
-[[feature]]
-id = "misc.adobe-express"
-title = "Adobe Express integration and generated images"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud"
-
-[[feature]]
-id = "misc.document-cloud-storage"
-title = "Adobe Document Cloud storage"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; local files and native dialogs (pluggable storage later)"
-
-[[feature]]
-id = "misc.share-links"
-title = "Share links and invitations"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; pluggable share provider"
-
-[[feature]]
-id = "misc.connected-storage"
-title = "Connected third-party storage accounts in-app"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe cloud; use OS file dialogs and synced folders, which can be pinned to Home (view.home-view)"
-
-[[feature]]
-id = "misc.cross-device-sync"
-title = "Sync signatures, stamps and preferences across devices"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Adobe account service"
-
-[[feature]]
-id = "misc.adobe-account"
-title = "Adobe account sign-in"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "not applicable; no account needed"
-
-[[feature]]
-id = "misc.rich-media-preserve"
-title = "Preserve 3D, video and sound content"
-area = "N"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.rich-media-poster"
-title = "Show posters for rich media"
-area = "N"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.rich-media-playback"
-title = "Play video and sound via the platform"
-area = "N"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.3d-view"
-title = "View 3D (U3D/PRC) content"
-area = "N"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.legacy-multimedia"
-title = "Legacy multimedia (screen annotations, renditions)"
-area = "N"
-tier = "P3"
-milestone = "M12"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.flash-swf"
-title = "Flash/SWF content"
-area = "N"
-tier = "N/A"
-milestone = "none"
-status = "na"
-commands = []
-tools = []
-evidence = []
-notes = "Flash is end-of-life; content is preserved but not played"
-
-[[feature]]
-id = "misc.preferences-dialog"
-title = "Preferences dialog generated from a schema"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "partial"
-commands = ["app.preferences"]
-tools = ["js_enabled"]
-evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override", "apps/pdfcraft/src/settings.rs::xdg_preferences_use_config_instead_of_data", "apps/pdfcraft/src/settings.rs::migration_preserves_preferences_and_other_data"]
-notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet. XDG desktops store app.ron under XDG_CONFIG_HOME/pdfcraft or ~/.config/pdfcraft; existing data-folder preferences are copied without replacing configured preferences, with fallback on migration errors. Logs and recovery retain data locations. Portable/non-XDG locations are unchanged."
-
-[[feature]]
-id = "misc.prefs-general"
-title = "Preferences: general"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-page-display"
-title = "Preferences: page display"
-area = "N"
-tier = "P1"
-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"
-
-[[feature]]
-id = "misc.prefs-documents"
-title = "Preferences: documents (autosave interval, recent count)"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-commenting"
-title = "Preferences: commenting"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-forms"
-title = "Preferences: forms"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-identity"
-title = "Preferences: identity"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-full-screen"
-title = "Preferences: full screen"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-search"
-title = "Preferences: search"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-signatures"
-title = "Preferences: signatures"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-security"
-title = "Preferences: security and trust"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-spelling"
-title = "Preferences: spelling"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-language"
-title = "Preferences: language"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "partial"
-commands = ["app.preferences"]
-tools = []
-evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"]
-notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish translates them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit."
-evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"]
-notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish and Italian translate them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit."
-
-[[feature]]
-id = "misc.prefs-reading"
-title = "Preferences: reading"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-accessibility"
-title = "Preferences: accessibility"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-color-management"
-title = "Preferences: colour management"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-javascript"
-title = "Preferences: JavaScript"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "shipped"
-commands = ["app.preferences"]
-tools = ["js_enabled"]
-evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"]
-notes = "Enable Acrobat JavaScript, remembered between runs."
-
-[[feature]]
-id = "misc.prefs-measuring"
-title = "Preferences: measuring"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-units-guides"
-title = "Preferences: units and guides"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-content-editing"
-title = "Preferences: content editing"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-convert"
-title = "Preferences: convert to and from PDF"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.prefs-catalog"
-title = "Preferences: catalog / index"
-area = "N"
-tier = "P3"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.admin-preferences"
-title = "Admin-deployable preference defaults"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.localization"
-title = "Localization (Fluent catalogs)"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "partial"
-commands = []
-tools = []
-evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"]
-notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Chinese shows tofu in releases (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic is not mirrored (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it."
-
-[[feature]]
-id = "misc.rtl-ui"
-title = "Right-to-left user interface"
-area = "N"
-tier = "P2"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = "Only a first step exists: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop) and are shown in display order (crates/ui-egui/src/bidi.rs). Missing: multi-line text (bookmarks, comments, search results), text input, a mirrored layout and a right-to-left interface language."
-
-[[feature]]
-id = "misc.installers"
-title = "Signed installers for macOS, Windows and Linux"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "partial"
-commands = []
-tools = []
-evidence = []
-notes = "Release builds provide signed and notarized macOS artifacts and code-signed Windows installers when signing credentials are configured (packaging/macos/package.sh; packaging/windows/sign.ps1; README.md). Linux AppImage, Flatpak, deb and rpm packages ship, but are not code-signed. Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut and an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682), and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported."
-notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut, an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682) and an optional (default on, INSTALLIMAGECONTEXTMENU) \"Create PDF with PdfCraft…\" entry for images; an update keeps whichever of the three the installed version has (#1058, #305). It confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned."
-
-[[feature]]
-id = "misc.web-deploy"
-title = "Hosted web app deployment"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.performance-budgets"
-title = "Meet performance budgets on Tier-1 platforms"
-area = "N"
-tier = "P0"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.send-email"
-title = "Send the file by email (system mail client)"
-area = "N"
-tier = "P3"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
-
-[[feature]]
-id = "misc.user-docs"
-title = "User documentation and keyboard reference"
-area = "N"
-tier = "P1"
-milestone = "M14"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
+# PdfCraft parity checklist vs Acrobat Pro (offline feature set). Generated from plan docs; maintained by hand.
+# Validated and reported by `cargo xtask parity`. Schema: see the [schema] table.
+# Status rules: `shipped` = usable today with at least one cited test; `partial` = part exists (notes say what is
+# missing); `planned` = not started; `na` = Adobe-cloud-only (notes name the local or pluggable alternative).
+# `commands` lists registry ids from crates/engine/src/commands.rs only; `tools` lists crates/automation/src/tools.rs names.
+[schema]
+version = 1
+statuses = ["planned", "partial", "shipped", "na"]
+tiers = ["P0", "P1", "P2", "P3", "N/A"]
+areas = { A = "core", B = "view", C = "edit", D = "organize", E = "comment", F = "form", G = "protect", H = "sign", I = "ocr", J = "create", K = "optimize", L = "print", M = "a11y", N = "misc" }
+
+# ==================================================================================================
+# A. Core document model and fidelity
+# ==================================================================================================
+
+[[feature]]
+id = "core.word-count"
+title = "Word and character count"
+area = "A"
+tier = "P2"
+milestone = "M2"
+status = "shipped"
+commands = ["file.word_count"]
+tools = ["text_word_count"]
+evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"]
+notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md."
+
+[[feature]]
+id = "core.open-pdf"
+title = "Open PDF files"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.open"]
+tools = ["doc_open"]
+evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits"]
+notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
+
+[[feature]]
+id = "core.repair-broken-files"
+title = "Open damaged files by reconstructing the cross-reference table"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.open"]
+tools = ["doc_open"]
+evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow"]
+notes = ""
+
+[[feature]]
+id = "core.malformed-input-safety"
+title = "Reject garbage or hostile input without crashing"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully"]
+notes = "fuzzed nightly (M1.10)"
+
+[[feature]]
+id = "core.repair-log"
+title = "Record repairs and show them to the user"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = ["doc_info"]
+evidence = ["crates/ui-egui/tests/viewer.rs::damaged_files_say_they_were_repaired"]
+notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them"
+
+[[feature]]
+id = "core.xref-streams"
+title = "Read cross-reference streams and compressed object streams"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/cos/tests/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"]
+notes = ""
+
+[[feature]]
+id = "core.hybrid-xref"
+title = "Hybrid-reference files (/XRefStm) and quirky xref subsections"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = ["doc_open"]
+evidence = ["crates/cos/src/document.rs::hybrid_reference_files_read_the_xref_stream_too"]
+notes = "/XRefStm entries supplement the classic table and replace its free entries for objects in object streams; each section is merged as a unit so older revisions only fill gaps"
+
+[[feature]]
+id = "core.revision-chain"
+title = "Follow /Prev revision chains with loop protection"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_revisions"]
+evidence = ["crates/cos/src/document.rs::looping_revision_chains_stop", "crates/cos/src/document.rs::revision_ends_split_incremental_updates"]
+notes = "/Prev chains followed newest first with a visited set (loops stop and are logged); revisions listed and opened (doc_revisions, doc_open_revision)"
+
+[[feature]]
+id = "core.stream-filters"
+title = "Standard stream filters (Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors"]
+notes = ""
+
+[[feature]]
+id = "core.image-filters"
+title = "Image codecs in the filters crate (DCT, JPX, JBIG2, CCITT)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "partial"
+commands = []
+tools = []
+evidence = []
+notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2"
+
+[[feature]]
+id = "core.decompression-limits"
+title = "Bounded decompression (filter bomb protection)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/filters/src/flate.rs::bomb_is_bounded", "crates/filters/src/lib.rs::limit_applies_to_intermediate_buffers"]
+notes = ""
+
+[[feature]]
+id = "core.open-rc4"
+title = "Open RC4-encrypted documents (40/128-bit)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.open"]
+tools = ["doc_open"]
+evidence = ["crates/crypt/src/tests.rs::rc4_reference_vectors", "crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"]
+notes = ""
+
+[[feature]]
+id = "core.open-aes"
+title = "Open AES-128 and AES-256 encrypted documents (R4-R6)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.open"]
+tools = ["doc_open"]
+evidence = ["crates/crypt/src/tests.rs::aes_cbc_nist_sp800_38a_vectors", "crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"]
+notes = ""
+
+[[feature]]
+id = "core.password-prompt"
+title = "Ask for the user or owner password, with retry"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.open"]
+tools = ["doc_open"]
+evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt"]
+notes = ""
+
+[[feature]]
+id = "core.unicode-passwords"
+title = "Unicode passwords normalised with SASLprep"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/crypt/src/tests.rs::r6_passwords_are_saslprepped"]
+notes = ""
+
+[[feature]]
+id = "core.owner-password-unlock"
+title = "Owner password lifts document restrictions"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["protect.unlock"]
+tools = ["doc_open"]
+evidence = ["crates/engine/src/tests.rs::restricted_documents_refuse_changes_unless_opened_by_the_owner", "crates/engine/src/tests.rs::owner_password_of_older_revisions_opens_the_viewer_too", "crates/crypt/src/tests.rs::owner_authentication_recovers_the_user_password", "crates/engine/src/tests.rs::the_permissions_password_unlocks_an_open_document", "crates/ui-egui/tests/editing.rs::the_security_notice_unlocks_with_the_permissions_password", "crates/ui-egui/tests/editing.rs::remove_security_asks_for_the_permissions_password_first"]
+notes = "at open (the password prompt takes either password), or after opening a restricted file that needed no password (#995, #1077): Unlock… on the security notice and in Properties ▸ Security, and changing security asks for it first. Not yet: an automation tool to unlock an open document (reopen with doc_open and the password instead)"
+
+[[feature]]
+id = "core.permissions-enforced"
+title = "Honour document permissions (print, modify, assemble, copy)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"]
+notes = ""
+
+[[feature]]
+id = "core.crypt-filters"
+title = "Crypt filters (Identity, /StmF, /StrF)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone"]
+notes = ""
+
+[[feature]]
+id = "core.attachment-only-encryption"
+title = "Attachment-only encryption (/EFF) opens without a password"
+area = "A"
+tier = "P1"
+milestone = "M1"
+status = "partial"
+commands = []
+tools = []
+evidence = []
+notes = "two corpus files still ask for a password (M1.6)"
+
+[[feature]]
+id = "core.unencrypted-metadata"
+title = "Leave XMP metadata readable when /EncryptMetadata is false"
+area = "A"
+tier = "P1"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/cos/tests/encryption.rs::unencrypted_metadata_is_left_readable"]
+notes = ""
+
+[[feature]]
+id = "core.unsupported-handlers"
+title = "Clear message for unsupported security handlers"
+area = "A"
+tier = "P1"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/crypt/src/tests.rs::unsupported_handlers_are_reported"]
+notes = ""
+
+[[feature]]
+id = "core.open-public-key"
+title = "Open certificate-encrypted (public-key handler) documents"
+area = "A"
+tier = "P1"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.incremental-save"
+title = "Save as an incremental update (original bytes kept, atomic write)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.save"]
+tools = ["doc_save"]
+evidence = ["crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"]
+notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI)."
+
+[[feature]]
+id = "core.save-as-full-rewrite"
+title = "Save as a new file with a full rewrite and garbage collection"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.save_as"]
+tools = ["doc_save"]
+evidence = ["crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"]
+notes = ""
+
+[[feature]]
+id = "core.object-stream-compression"
+title = "Compress objects into object streams behind an xref stream on full save"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.save_as"]
+tools = ["doc_save"]
+evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/cos/tests/writer.rs::objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams"]
+notes = ""
+
+[[feature]]
+id = "core.encrypted-save"
+title = "Save encrypted documents re-encrypted under the same keys"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = ["file.save"]
+tools = ["doc_save"]
+evidence = ["crates/cos/tests/encryption.rs::incremental_edits_of_encrypted_documents_stay_encrypted", "crates/engine/src/tests.rs::encrypted_documents_open_edit_and_save_encrypted"]
+notes = ""
+
+[[feature]]
+id = "core.copy-on-write-fidelity"
+title = "Preserve untouched objects byte-for-byte (copy-on-write edits)"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/cos/src/document.rs::reads_classic_revision_and_edits_are_copy_on_write"]
+notes = ""
+
+[[feature]]
+id = "core.write-validity"
+title = "Written files pass qpdf --check"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/automation.rs::a_root_less_write_output_is_atomic_too"]
+notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle). Every PDF leaves the engine checked (it reopens with its pages): saves, new files from Combine/Extract/Split/Create, signed, timestamped and LTV-embedded files, OCR batch output; and every writer goes through one staging-file writer (the desktop app, the tools with or without --root, the CLI), so a failed write never leaves a truncated file (#1698, #2166)"
+
+[[feature]]
+id = "core.deterministic-save"
+title = "Deterministic full-save mode"
+area = "A"
+tier = "P2"
+milestone = "M1"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "M1.9 option"
+
+[[feature]]
+id = "core.linearization"
+title = "Linearized save (Fast Web View)"
+area = "A"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.revision-list"
+title = "List document revisions from the xref chain"
+area = "A"
+tier = "P1"
+milestone = "M1"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_revisions"]
+evidence = ["crates/cos/src/document.rs::revision_ends_split_incremental_updates", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "revision ends from the xref chain (linearized first-page sections fold into their revision); listed in Document Properties ▸ Advanced and by doc_revisions with the signatures covering each"
+
+[[feature]]
+id = "core.view-revision"
+title = "View an earlier revision as a read-only snapshot"
+area = "A"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_open_revision"]
+evidence = ["crates/ui-egui/tests/viewer.rs::an_earlier_revision_opens_from_document_properties", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "opens the revision's bytes as a new unsaved document named ' (revision n)'"
+
+[[feature]]
+id = "core.lazy-loading"
+title = "Lazy loading of very large (GB) files within a memory budget"
+area = "A"
+tier = "P0"
+milestone = "M1"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "ByteSource (M1.5); files are buffered whole today. Viewing holds the file once and editing twice: edits let go of the previous working file before writing the next (a 1 GiB file peaked at three copies after a few edits, now two). Saving still peaks at three. Reading from disk on demand waits on the renderer (hayro parses one contiguous buffer, gap 2)."
+
+[[feature]]
+id = "core.pdf20-utf8-strings"
+title = "PDF 2.0 UTF-8 text strings"
+area = "A"
+tier = "P1"
+milestone = "M2"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.pdf20-associated-files"
+title = "PDF 2.0 associated files (/AF)"
+area = "A"
+tier = "P1"
+milestone = "M2"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.pdf20-dpart"
+title = "PDF 2.0 document parts (DPart)"
+area = "A"
+tier = "P2"
+milestone = "M2"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.pdf20-namespaces"
+title = "PDF 2.0 structure namespaces"
+area = "A"
+tier = "P1"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.arlington-validation"
+title = "Structural validation against the Arlington PDF model"
+area = "A"
+tier = "P1"
+milestone = "M2"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.undo-redo"
+title = "Undo and redo every document edit"
+area = "A"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["edit.undo", "edit.redo"]
+tools = ["edit_undo", "edit_redo"]
+evidence = ["crates/engine/src/tests.rs::undo_and_redo_restore_exact_states", "crates/ui-egui/tests/editing.rs::organize_delete_then_undo_and_redo_with_keys"]
+notes = ""
+
+[[feature]]
+id = "core.undo-labels"
+title = "Edit menu names the step to undo or redo"
+area = "A"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["edit.undo", "edit.redo"]
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::edit_menu_names_the_step_to_undo", "crates/engine/src/commands.rs::enablement_follows_the_document_state"]
+notes = ""
+
+[[feature]]
+id = "core.undo-after-save"
+title = "Undo still works after saving"
+area = "A"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["edit.undo"]
+tools = ["edit_undo"]
+evidence = ["crates/engine/src/tests.rs::undo_still_works_after_save"]
+notes = ""
+
+[[feature]]
+id = "core.atomic-batch-edits"
+title = "Multi-step edits are one undo step and all-or-nothing"
+area = "A"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/engine/src/tests.rs::batch_is_one_undo_step_and_all_or_nothing", "crates/engine/src/tests.rs::failed_edit_changes_nothing"]
+notes = ""
+
+[[feature]]
+id = "core.history-panel"
+title = "History panel with named snapshots"
+area = "A"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.autosave"
+title = "Autosave changed documents periodically"
+area = "A"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/engine/src/tests.rs::autosave_snapshots_only_changed_documents", "crates/ui-egui/tests/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents"]
+notes = ""
+
+[[feature]]
+id = "core.crash-recovery"
+title = "Offer to recover unsaved work after a crash"
+area = "A"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"]
+notes = "A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before."
+
+[[feature]]
+id = "core.encrypted-autosave"
+title = "Autosaves of encrypted documents stay encrypted"
+area = "A"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/recovery.rs::encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password"]
+notes = ""
+
+[[feature]]
+id = "core.web-recovery"
+title = "Crash recovery in the web app"
+area = "A"
+tier = "P1"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "core.unsaved-changes-prompt"
+title = "Ask before closing or quitting with unsaved changes"
+area = "A"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["file.close"]
+tools = ["doc_close"]
+evidence = ["crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename"]
+notes = ""
+
+[[feature]]
+id = "core.dirty-indicator"
+title = "Unsaved-changes marker on document tabs"
+area = "A"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::organize_delete_then_undo_and_redo_with_keys", "crates/engine/src/tests.rs::edits_update_view_data_and_mark_dirty"]
+notes = ""
+
+[[feature]]
+id = "core.close-document"
+title = "Close a document"
+area = "A"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["file.close"]
+tools = ["doc_close"]
+evidence = ["crates/ui-egui/tests/editing.rs::clean_tabs_close_without_asking"]
+notes = ""
+
+[[feature]]
+id = "core.close-all"
+title = "Close all documents"
+area = "A"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["file.close_all"]
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::close_all_asks_only_for_changed_documents"]
+notes = "clean documents close; each changed one asks"
+
+[[feature]]
+id = "core.revert"
+title = "Revert to the last saved version"
+area = "A"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["file.revert"]
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::revert_after_confirming", "crates/engine/src/tests.rs::revert_goes_back_to_the_saved_version"]
+notes = "with confirmation; clears undo, as Acrobat"
+
+[[feature]]
+id = "core.reload-changed-files"
+title = "Reload a document when its file changes on disk"
+area = "A"
+tier = "P2"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one", "crates/ui-egui/tests/reload.rs::saving_over_a_file_changed_by_something_else_asks_first", "crates/ui-egui/tests/reload.rs::saving_checks_the_file_with_the_watcher_off_too", "crates/ui-egui/tests/reload.rs::opening_a_file_that_is_open_focuses_its_tab"]
+notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Save asks before writing over a version of the file the document did not come from, watcher on or off, with Save as… to keep it (#2166); a file that is open already comes to the front instead of opening twice. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert."
+
+[[feature]]
+id = "core.signed-save-policy"
+title = "Force incremental saves for signed documents"
+area = "A"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["file.save"]
+tools = ["doc_save"]
+evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"]
+notes = "saves of signed documents are always incremental: full rewrites are refused, and edits that would need one (applying redactions, changing security, sanitizing) are refused before they change anything"
+
+[[feature]]
+id = "core.drag-drop-open"
+title = "Open files by drag and drop"
+area = "A"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it"]
+notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously"
+
+[[feature]]
+id = "core.recent-files"
+title = "Local recent-files list"
+area = "A"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["file.open_recent", "file.clear_recent"]
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/ui.rs::home_removes_one_recent_file_or_clears_them_all"]
+notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it; Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)"
+
+[[feature]]
+id = "core.reopen-last-session"
+title = "Reopen the files from the last session at startup"
+area = "A"
+tier = "P2"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"]
+notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Not yet: zoom, layout and scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen."
+
+[[feature]]
+id = "core.usage-rights-preserve"
+title = "Preserve usage-rights (UR3) signatures from Reader-extended files"
+area = "A"
+tier = "P3"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "preserve only"
+
+# ==================================================================================================
+# B. Viewing and navigation
+# ==================================================================================================
+
+[[feature]]
+id = "view.render-pages"
+title = "Render pages (text, vectors, images)"
+area = "B"
+tier = "P0"
+milestone = "M2"
+status = "partial"
+commands = []
+tools = ["page_render"]
+evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/raster.rs::user_unit_scales_the_page_and_everything_on_it"]
+notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value"
+
+[[feature]]
+id = "view.render-shadings-patterns"
+title = "Shadings, gradients and tiling patterns"
+area = "B"
+tier = "P0"
+milestone = "M2"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::self_referencing_tiling_pattern_terminates"]
+notes = "via hayro bootstrap"
+
+[[feature]]
+id = "view.render-transparency"
+title = "Blend modes, soft masks and transparency groups"
+area = "B"
+tier = "P0"
+milestone = "M2"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"]
+notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones"
+
+[[feature]]
+id = "view.render-knockout"
+title = "Knockout transparency groups"
+area = "B"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = []
+tools = ["doc_save"]
+evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "full rewrites of signed documents are refused; saves stay incremental"
+
+[[feature]]
+id = "view.render-type3"
+title = "Type 3 fonts"
+area = "B"
+tier = "P0"
+milestone = "M2"
+status = "partial"
+commands = []
+tools = []
+evidence = []
+notes = "hayro bootstrap with vendored patches"
+
+[[feature]]
+id = "view.render-cid-fonts"
+title = "CID fonts and CMaps incl. CJK without ToUnicode"
+area = "B"
+tier = "P0"
+milestone = "M2"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana"]
+notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face)"
+
+[[feature]]
+id = "view.render-image-codecs"
+title = "JBIG2, JPEG 2000, CCITT and JPEG images"
+area = "B"
+tier = "P0"
+milestone = "M2"
+status = "partial"
+commands = []
+tools = []
+evidence = []
+notes = "hayro bootstrap"
+
+[[feature]]
+id = "view.render-annotation-appearances"
+title = "Annotation and widget appearances incl. appearance states"
+area = "B"
+tier = "P0"
+milestone = "M2"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source"]
+notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet"
+
+[[feature]]
+id = "view.render-error-isolation"
+title = "Per-page error isolation (a bad page never takes down the app)"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"]
+notes = ""
+
+[[feature]]
+id = "view.render-priority"
+title = "Render visible pages first, then neighbours and thumbnails"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::inline_pool_renders_in_priority_order"]
+notes = ""
+
+[[feature]]
+id = "view.deep-zoom-tiles"
+title = "Tiled rendering keeps deep zoom sharp"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"]
+notes = "capped at 8192 px per side (M3.3); tiles of a zoom already rendered stay cached while their page is on screen, so zooming away and back shows the area sharp instead of re-rendering it tile by tile (#2196)"
+
+[[feature]]
+id = "view.layout-continuous"
+title = "Single page continuous scrolling"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.continuous"]
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"]
+notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)"
+
+[[feature]]
+id = "view.layout-single-page"
+title = "Single page view"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.layout.single"]
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"]
+notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette"
+
+[[feature]]
+id = "view.layout-two-up"
+title = "Two-page view"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.layout.two_up"]
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/viewer.rs::two_page_view_steps_a_spread_at_a_time"]
+notes = "next/previous page step a spread at a time"
+
+[[feature]]
+id = "view.layout-two-up-scrolling"
+title = "Two-page scrolling"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"]
+notes = "the two-page view scrolls continuously"
+
+[[feature]]
+id = "view.cover-page"
+title = "Show cover page in two-page view"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["view.layout.cover"]
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/viewer.rs::page_down_steps_every_spread_when_several_fit_on_screen"]
+notes = "View ▸ Page display, the rail's Page display button or the palette; disabled outside two-page view; next / previous page step one spread at a time even when several spreads fit on screen"
+
+[[feature]]
+id = "view.page-gaps"
+title = "Show or hide gaps between pages"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.auto-scroll"
+title = "Automatic scrolling"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"]
+notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented."
+
+[[feature]]
+id = "view.zoom-in-out"
+title = "Zoom in and out around the cursor (keys, wheel, pinch)"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::zoom_keeps_the_point_under_the_cursor_still", "crates/ui-egui/tests/ui.rs::trackpad_pinch_zooms_the_pdf_under_the_pointer"]
+notes = "A trackpad pinch zooms the PDF beneath the pointer in either direction, keeping the same page point in place. The viewer handles the platform's zoom gesture where it is available."
+
+[[feature]]
+id = "view.zoom-to-percent"
+title = "Zoom to a chosen percentage"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"]
+notes = "PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display"
+
+[[feature]]
+id = "view.zoom-actual-size"
+title = "Actual size"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"]
+notes = "Actual size is 100% on PdfCraft's fixed 96-dpi basis: 96/72 logical pixels per PDF point. Acrobat's Actual Size uses its Page Display resolution (ppi / 72 logical pixels per PDF point at 100%), which PdfCraft does not offer yet, so the two can draw a page at different sizes. Tracked with misc.prefs-page-display"
+
+[[feature]]
+id = "view.zoom-fit-page"
+title = "Fit page"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.fit_one_page"]
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices", "crates/ui-egui/tests/view.rs::fit_page_holds_zoom_across_mixed_page_sizes", "crates/ui-egui/tests/view.rs::single_page_fit_page_fits_the_shown_page"]
+notes = "View ▸ Zoom to page level (⌘0) and the rail's Page display menu, which also offers Fit one full page (single-page view at Fit page). Scrolling views fit their largest page so mixed sizes never jump the zoom; single-page view fits the shown page"
+
+[[feature]]
+id = "view.zoom-fit-width"
+title = "Fit width"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.fit_width_scrolling"]
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices"]
+notes = "View ▸ Fit to width (⌘2) and the rail's Page display menu's Fit to width scrolling (continuous scrolling at Fit width)"
+
+[[feature]]
+id = "view.zoom-fit-height"
+title = "Fit height"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"]
+notes = "View ▸ Fit to height"
+
+[[feature]]
+id = "view.zoom-fit-visible"
+title = "Fit visible content"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["view.fit_visible"]
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"]
+notes = "View ▸ Zoom ▸ Fit visible (⌘3): the current page's inked area (found by a quick low-resolution render) spans the window width; respects view rotation; drivable via the control channel's command"
+
+[[feature]]
+id = "view.marquee-zoom"
+title = "Marquee zoom tool"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["view.marquee_zoom"]
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"]
+notes = "drag to fill the window with an area; click to zoom in"
+
+[[feature]]
+id = "view.dynamic-zoom"
+title = "Dynamic zoom (drag to zoom)"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.loupe"
+title = "Loupe magnifier tool"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.pan-and-zoom"
+title = "Pan and zoom overview window"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.hand-tool"
+title = "Hand tool for panning"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"]
+notes = "drag to pan; Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily"
+
+[[feature]]
+id = "view.rotate-view"
+title = "Rotate the view without changing the file"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"]
+notes = ""
+
+[[feature]]
+id = "view.read-mode"
+title = "Read mode (hide tool chrome)"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.read_mode"]
+tools = []
+evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"]
+notes = ""
+
+[[feature]]
+id = "view.full-screen"
+title = "Full screen mode"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.full_screen"]
+tools = []
+evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves"]
+notes = ""
+
+[[feature]]
+id = "view.full-screen-presentation"
+title = "Full-screen presentation options (auto-advance, loop, click to advance)"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.reflow"
+title = "Reflow text to the window width"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.page-navigation"
+title = "Page navigation (next, previous, first, last, go to page)"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"]
+notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"
+
+[[feature]]
+id = "view.goto-page-label"
+title = "Go to a page by its label (logical page numbers)"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"]
+notes = "the page box takes labels (iv, A-1) before numbers"
+
+[[feature]]
+id = "view.view-history"
+title = "Previous view / next view"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["view.previous_view", "view.next_view"]
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/control.rs::routine_continuous_scrolling_does_not_add_navigation_history"]
+notes = "Per-tab page history: explicit search-result jumps, page navigation, bookmarks and links; no continuous-scroll history or restart persistence; existing page-step history is retained. Rail buttons, View ▸ Page navigation and Ctrl/⌘[ / Ctrl/⌘]. UI control ui.command exposes view.previous_view / view.next_view; standalone engine sessions have no viewport history. Restores page tops, not exact scroll/zoom positions."
+
+[[feature]]
+id = "view.follow-links"
+title = "Follow links and destinations"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = ["link_open"]
+evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords"]
+notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. URL/network file systems, GoToE, Launch and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit."
+
+[[feature]]
+id = "view.annotation-hover"
+title = "Show comment text on hover"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text"]
+notes = "a popup with the author and the comment text, as Acrobat shows"
+
+[[feature]]
+id = "view.page-size-hover"
+title = "Page size on hover (bottom-left of the page view)"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas.rs::page_size_hint_zone_is_the_bottom_left_quadrant"]
+notes = "While the pointer is in the bottom-left quadrant of the page view, a small label shows the current page's size (after /Rotate and /UserUnit) in the Preferences unit, formatted as in Document Properties (#1855). It never takes clicks and hides when the pointer leaves. Acrobat's trigger is the corner itself and may differ in exact area and wording (not compared with real Acrobat); the size of the current page only, not of the page under the pointer; no headless tool (page sizes are in document info)."
+
+[[feature]]
+id = "view.document-tabs"
+title = "Tabbed documents"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["view.next_tab", "view.previous_tab"]
+tools = ["doc_list"]
+evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch", "crates/ui-egui/tests/ui.rs::dragging_a_tab_moves_it_and_keeps_the_same_document_active", "crates/ui-egui/tests/ui.rs::a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it", "crates/ui-egui/tests/ui.rs::the_combine_tab_does_not_move_and_documents_stay_in_order", "crates/ui-egui/tests/commands.rs::ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around"]
+notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll. Document tabs are dragged to reorder them (#1537): a line marks where the tab will land, the strip scrolls at its edges when it overflows, and the same document stays active; the Combine files tab stays after the documents. Dragging a tab out into its own window is view.multiple-windows. Ctrl+Tab and Ctrl+Shift+Tab (⌃Tab and ⌃⇧Tab on macOS; View ▸ Next tab / Previous tab) step through the tabs in their order on screen, Combine files included, and wrap around at either end (#1542)."
+
+[[feature]]
+id = "view.multiple-windows"
+title = "Multiple windows; drag a tab out into a window"
+area = "B"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.split-window"
+title = "Split window (two panes on one document)"
+area = "B"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.spreadsheet-split"
+title = "Spreadsheet split (four synchronised panes)"
+area = "B"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.thumbnails-panel"
+title = "Page thumbnails panel"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"]
+notes = ""
+
+[[feature]]
+id = "view.thumbnail-context-ops"
+title = "Page operations from the thumbnails panel (drag reorder, context menu)"
+area = "B"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"]
+notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
+
+[[feature]]
+id = "view.bookmarks-panel"
+title = "Bookmarks panel with navigation"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = ["doc_info"]
+evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/view.rs::bookmark_destinations_scroll_to_their_position"]
+notes = "A bookmark goes to its destination's position and zoom, not just its page. The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)."
+
+[[feature]]
+id = "view.highlight-current-bookmark"
+title = "Highlight the current bookmark"
+area = "B"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.attachments-panel"
+title = "Attachments panel: list, open and save embedded files"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = ["doc_info"]
+evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted"]
+notes = ""
+
+[[feature]]
+id = "view.layers-panel"
+title = "Layers panel: toggle optional content visibility"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = ["doc_info"]
+evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits"]
+notes = ""
+
+[[feature]]
+id = "view.layers-reset"
+title = "Reset layers to initial visibility; list for all or current pages"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.destinations-panel"
+title = "Named destinations panel"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.articles-panel"
+title = "Articles panel and article reading"
+area = "B"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.model-tree"
+title = "3D model tree panel"
+area = "B"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.page-labels-display"
+title = "Show page labels (i, ii, 1, 2...)"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = ["doc_info"]
+evidence = ["crates/render/src/inspect.rs::roman_and_alpha_labels", "crates/automation/tests/automation.rs::open_inspect_render_and_find"]
+notes = ""
+
+[[feature]]
+id = "view.find"
+title = "Find text across the document"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["edit.find"]
+tools = ["text_find"]
+evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/ui.rs::find_reports_no_matches", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/text.rs::identity_v_columns_are_searchable_in_reading_order"]
+notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Complex interleaved flows still use a geometric reading-order heuristic."
+
+[[feature]]
+id = "view.find-next-previous"
+title = "Find next / previous match"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["edit.find"]
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages"]
+notes = ""
+
+[[feature]]
+id = "view.find-options"
+title = "Find options: whole words, case-sensitive"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["edit.find"]
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"]
+notes = "whole words only, case-sensitive"
+
+[[feature]]
+id = "view.find-in-comments-bookmarks"
+title = "Find in comments and bookmarks"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.find-results-panel"
+title = "Find results list"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["edit.advanced_search"]
+tools = ["text_find"]
+evidence = ["crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context"]
+notes = "Search panel (⇧⌘F): every match grouped by page with its context, whole words / case options, click to go; shares the find bar's search (closing the panel moves it to the bar)"
+
+[[feature]]
+id = "view.advanced-search"
+title = "Advanced search window"
+area = "B"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["edit.advanced_search"]
+tools = ["text_find"]
+evidence = ["crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context"]
+notes = "in the current document, as a side panel; searching other open documents, folders and indexes is still to come"
+
+[[feature]]
+id = "view.search-folder"
+title = "Search all PDFs in a folder"
+area = "B"
+tier = "P1"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.search-boolean"
+title = "Boolean, proximity and stemming search"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.search-index"
+title = "Build and search a full-text index across files"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.embedded-index"
+title = "Embedded search index"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.text-select"
+title = "Select text in reading order"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/ui_group/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/comments.rs::selected_text_comment_action_opens_note_composer"]
+notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection. The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar."
+
+[[feature]]
+id = "view.column-select"
+title = "Column select (Alt/Option-drag a rectangle of text)"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "shipped"
+commands = ["edit.column_select"]
+tools = ["text_extract"]
+evidence = ["crates/render/src/text.rs::a_column_selection_takes_one_table_column", "crates/render/src/text.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/ui_group/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/ui_group/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/ui_group/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/automation.rs::text_extract_with_a_rect_takes_one_column"]
+notes = "Issue #740. Holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, with the Select tool, the text markup tools and Redact (with Highlight and Redact the drag must start on text, since off text they draw an area box). Edit ▸ Column select does the same with a plain drag. A glyph counts when its centre is inside. Copy gives one row per visual line, top to bottom; pieces side by side (table cells) are separated by a tab, right to left in right-to-left rows; vertical text keeps reading order. text_extract takes the same rectangle as rect. Platforms: on Linux desktops whose window manager moves windows with Alt-drag (Xfce, Cinnamon and MATE by default; KDE Plasma 5 and other setups with Alt as the window modifier; GNOME and Plasma 6 use Super) the drag never reaches PdfCraft, so use Edit ▸ Column select or change that setting. On Windows, releasing Alt does not open a system menu (the window has no native menu). The web build has the same Linux caveat in the browser. Checked by hand on Linux (Fedora) with the Column select tool; Alt/Option-drag, and macOS and Windows generally, rest on the kittest tests. Missing: selection across pages, ⇧-click extending a column, a default shortcut, and a check of Acrobat's own copied-text format (not observed)."
+
+[[feature]]
+id = "view.copy-text"
+title = "Copy selected text"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["edit.copy"]
+tools = ["text_extract"]
+evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"]
+notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract."
+
+[[feature]]
+id = "view.select-all"
+title = "Select all text on a page"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"]
+notes = "⌘A on the current page"
+
+[[feature]]
+id = "view.copy-with-layout"
+title = "Copy text keeping layout"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.snapshot"
+title = "Snapshot tool (copy a region as an image)"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["edit.snapshot"]
+tools = ["page_render"]
+evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"]
+notes = "copies the area at the current zoom to the clipboard as an image"
+
+[[feature]]
+id = "view.copy-image"
+title = "Select an image on the page and copy it"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"]
+notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2/CCITT images, multi-format clipboard (file and bitmap at once), the web build"
+
+[[feature]]
+id = "view.rulers"
+title = "Rulers"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.grid"
+title = "Grid and snap to grid"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.guides"
+title = "Guides"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.line-weights"
+title = "Show line weights"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.cursor-coordinates"
+title = "Cursor coordinates"
+area = "B"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.theme-light-dark"
+title = "Light and dark themes"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["view.theme", "view.theme.light", "view.theme.dark"]
+tools = []
+evidence = ["crates/ui-egui/tests/ui_group/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme.rs::text_is_readable_on_every_surface"]
+notes = "Toolbar and Display theme share three choices and a preference setter; manual choices exit system mode. Every text colour meets WCAG AA (4.5:1) on every surface in both themes; glyphs are pixel-aligned for sharper text at 100–150 % scaling."
+
+[[feature]]
+id = "view.theme-system"
+title = "Follow the system theme"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["view.theme.system"]
+tools = []
+evidence = ["crates/ui-egui/tests/ui_group/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/ui_group/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/control.rs::theme_commands_and_options_report_preference_and_effective_colours"]
+notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately."
+
+[[feature]]
+id = "view.home-view"
+title = "Home view with recommended tools and recent files"
+area = "B"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["file.pin_folder"]
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/folders.rs::pinned_folders_survive_a_restart"]
+notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build."
+
+[[feature]]
+id = "view.all-tools"
+title = "All tools catalogue with milestone badges"
+area = "B"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui.rs::every_catalog_tool_is_listed_after_view_more", "crates/engine/src/commands.rs::every_ready_catalogue_item_is_a_registered_command"]
+notes = ""
+
+[[feature]]
+id = "view.command-palette"
+title = "Command palette (⌘K) over every command"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["view.palette"]
+tools = []
+evidence = ["crates/ui-egui/tests/commands.rs::the_palette_lists_commands_with_shortcuts_and_runs_them"]
+notes = ""
+
+[[feature]]
+id = "view.keyboard-shortcuts"
+title = "Platform keyboard shortcuts for registered commands"
+area = "B"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions", "crates/ui-egui/tests/commands.rs::quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work", "crates/ui-egui/tests/signing.rs::ctrl_alt_q_types_an_email_without_quitting"]
+notes = "Quit requires exact Ctrl+Q/Cmd+Q modifiers and preserves unsaved-document confirmation. Ctrl+Alt+Q email input is covered with synthetic key/text events; native German keyboard-layout translation is not covered by that test (#1980)."
+
+[[feature]]
+id = "view.shortcuts-dialog"
+title = "Keyboard shortcuts reference dialog"
+area = "B"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["help.shortcuts"]
+tools = []
+evidence = ["crates/ui-egui/tests/commands.rs::the_shortcuts_dialog_lists_the_real_bindings"]
+notes = ""
+
+[[feature]]
+id = "view.menus-from-registry"
+title = "Menus generated from the command registry"
+area = "B"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/commands.rs::the_pages_menu_comes_from_the_registry"]
+notes = ""
+
+[[feature]]
+id = "view.about-dialog"
+title = "About dialog"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "shipped"
+commands = ["help.about"]
+tools = []
+evidence = ["crates/ui-egui/tests/commands.rs::the_about_dialog_shows_the_running_version"]
+notes = ""
+
+[[feature]]
+id = "view.check-for-updates"
+title = "Help ▸ Check for updates"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "partial"
+commands = ["help.check_updates"]
+tools = []
+evidence = ["crates/ui-egui/tests/ui_group/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/ui_group/updates.rs::nothing_is_asked_until_the_user_checks", "apps/pdfcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"]
+notes = "asks GitHub Releases on request and offers the release page. By the owner's decision there is no check at start and no in-place install (Acrobat has both)"
+
+[[feature]]
+id = "view.single-key-accelerators"
+title = "Single-key tool accelerators"
+area = "B"
+tier = "P2"
+milestone = "M3"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.customize-quick-tools"
+title = "Customise the quick-action toolbar and pinned tools"
+area = "B"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.restriction-notice"
+title = "Message bar for restricted documents"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes"]
+notes = ""
+
+[[feature]]
+id = "view.pdfa-view-mode"
+title = "Read-only view for PDF/A files with an Enable editing bar"
+area = "B"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.show-page-boxes"
+title = "Show art, trim and bleed boxes"
+area = "B"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "view.web-app"
+title = "Run in the browser (WebAssembly)"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/pickers.rs::a_startup_url_opens_in_front_when_the_user_has_not_started_working", "crates/ui-egui/tests/pickers.rs::a_late_startup_url_preserves_the_current_edit_workspace_and_save_target", "crates/ui-egui/tests/pickers.rs::deliberate_browser_file_opens_still_activate_the_new_tab"]
+notes = "web build works; a late startup ?file= download opens in a background tab after the user starts working, preserving the active document and Save target (#171). IndexedDB/OPFS recent list and download flow are M3.10"
+
+[[feature]]
+id = "view.large-document-performance"
+title = "Smooth scrolling on 500-page documents"
+area = "B"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget", "crates/ui-egui/tests/perf.rs::panels_with_hundreds_of_items_stay_within_the_frame_budget"]
+notes = "UI work per frame ~6 ms scrolling 500 pages (comments panel with 500 cards ~6 ms, thumbnails ~2 ms); rendering is off-thread and tiled"
+
+# ==================================================================================================
+# C. Content editing
+# ==================================================================================================
+
+[[feature]]
+id = "edit.text-edit"
+title = "Edit existing text in place"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "partial"
+commands = ["edit.edit_text"]
+tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
+evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"]
+notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md"
+
+[[feature]]
+id = "edit.text-reflow"
+title = "Reflow edited text inside its paragraph box"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/edit/src/tests.rs::japanese_fallback_reports_the_face_after_save_and_reopen", "crates/edit/src/tests.rs::japanese_paragraph_keeps_ideographic_spaces", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
+notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin; U+3000 ideographic spaces are preserved (including paragraph edges and repeated spaces); Japanese Type 3 fallback reports its face through FontDescriptor/FontName after editing and save/reopen; source family/weight matching remains limited (#184)"
+
+[[feature]]
+id = "edit.text-move-resize"
+title = "Move and resize text boxes"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
+notes = "existing paragraphs: drag the box to move it, drag the handle on its right edge to rewrap it to a new width (upright pages); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)"
+
+[[feature]]
+id = "edit.paragraph-detection"
+title = "Detect paragraphs and text blocks for editing"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["text_paragraphs"]
+evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
+notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined"
+
+[[feature]]
+id = "edit.font-reuse-substitution"
+title = "Reuse embedded fonts or substitute with a warning"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/fonts/src/pdf.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf.rs::composite_fonts_use_to_unicode"]
+notes = "the line's font is reused when every character has a code and a glyph (ToUnicode, encodings, Differences; subset glyph checks); otherwise Helvetica (WinAnsi) is used and reported; text no font can show is refused"
+
+[[feature]]
+id = "edit.text-format-font"
+title = "Change font, size and colour"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text", "edit.text"]
+tools = ["text_edit", "content_update"]
+evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
+notes = "existing paragraphs and added text: font family (Helvetica, Times, Courier) with bold and italic, size, colour; existing paragraphs are rewrapped"
+
+[[feature]]
+id = "edit.text-format-style"
+title = "Bold, italic, underline"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
+notes = "bold and italic through the standard families; underline drawn under each line in the text colour"
+
+[[feature]]
+id = "edit.text-super-subscript"
+title = "Superscript and subscript"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.paragraph-alignment"
+title = "Paragraph alignment (left, centre, right, justify)"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text", "edit.text"]
+tools = ["text_edit", "content_update"]
+evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
+notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text"
+
+[[feature]]
+id = "edit.line-paragraph-spacing"
+title = "Line and paragraph spacing"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "partial"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
+notes = "line spacing (1, 1.15, 1.5, 2 × size or any multiple); space between paragraphs is not adjustable yet"
+
+[[feature]]
+id = "edit.char-spacing-scale"
+title = "Character spacing and horizontal scaling"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
+notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped"
+
+[[feature]]
+id = "edit.lists"
+title = "Bulleted and numbered lists"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.add-text"
+title = "Add a new text box as page content"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.text"]
+tools = ["page_add_text", "content_update"]
+evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"]
+notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
+
+[[feature]]
+id = "edit.find-replace"
+title = "Find and replace in edit mode"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.rtl-cjk-editing"
+title = "Edit right-to-left and CJK text"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "partial"
+commands = []
+tools = ["text_lines", "text_paragraphs", "text_edit"]
+evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip", "crates/edit/src/tests.rs::arabic_actual_text_keeps_typing_order_when_opened_for_editing", "crates/automation/tests/automation.rs::arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"]
+notes = "Added Arabic copy/search keeps typing order through per-line ActualText. Arabic/Hebrew ActualText is preserved in typing order rather than reordered twice; font-independent tests cover marks, numbers, Latin, reopening and complete replacement. This does not add Arabic fallback fonts or bidi caret/selection. Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK. Also (#1116): bounded single-face existing-text engine coverage now exists (Arabic marks/ligatures/mixed bidi and explicit full-face Chinese subset completion), with private keyboard/save/reopen checks. General mixed-font/style contextual runs, bidi caret/selection, legacy programs and release font availability remain incomplete, so broader feature remains planned"
+
+[[feature]]
+id = "edit.rotated-text-editing"
+title = "Edit rotated text"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.keep-tags-on-edit"
+title = "Keep structure tags when editing"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.image-add"
+title = "Add an image"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.image"]
+tools = ["page_add_image"]
+evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"]
+notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect"
+
+[[feature]]
+id = "edit.image-replace"
+title = "Replace an image (keep position and size)"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text", "edit.image"]
+tools = ["image_edit"]
+evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"]
+notes = "existing page images (right-click ▸ Replace Image) and added images: the new picture is drawn in the same place"
+
+[[feature]]
+id = "edit.image-crop"
+title = "Crop an image"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.image"]
+tools = ["content_update", "image_edit"]
+evidence = ["crates/ui-egui/tests/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"]
+notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed."
+
+[[feature]]
+id = "edit.image-rotate-flip"
+title = "Rotate and flip images"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["image_edit"]
+evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"]
+notes = "existing raster images and grouped Form artwork: rotate either way about the centre, flip horizontally or vertically (a q/cm/Q wrapper); Forms stay intact as groups; added images too"
+
+[[feature]]
+id = "edit.image-move-resize"
+title = "Move and resize images"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["image_edit", "image_hit"]
+evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"]
+notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
+
+[[feature]]
+id = "edit.image-extract"
+title = "Save an image to a file"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["image_save"]
+evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"]
+notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG"
+
+[[feature]]
+id = "edit.image-external-editor"
+title = "Edit an image in an external application"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.arrange-objects"
+title = "Arrange objects (bring forward, send backward)"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "partial"
+commands = []
+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"
+title = "Align and distribute objects"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.vector-edit"
+title = "Select, move, scale, recolour and delete vector paths"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.edit-object-tool"
+title = "Edit object tool for any page object"
+area = "C"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["edit.edit_text"]
+tools = ["object_list", "object_move"]
+evidence = ["crates/ui-egui/tests/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen"]
+notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md."
+
+[[feature]]
+id = "edit.object-properties"
+title = "Object properties (colour space, font, convert colour)"
+area = "C"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.link-create"
+title = "Create web and page links"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.link"]
+tools = ["link_add"]
+evidence = ["crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"]
+notes = "drag a rectangle; go to a page or open a web page"
+
+[[feature]]
+id = "edit.link-properties"
+title = "Link appearance and properties"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.link"]
+tools = ["link_edit"]
+evidence = ["crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"]
+notes = "visible/invisible rectangle, highlight, thickness, solid/dashed/underline and colour; supported page/URI action editing. Appearance-only edits preserve unsupported actions, including GoToR."
+
+[[feature]]
+id = "edit.links-from-urls"
+title = "Create links from URLs in the text"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.links_from_urls"]
+tools = ["links_from_urls"]
+evidence = ["crates/engine/src/tests.rs::links_from_urls_and_link_edits", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::urls_are_found_in_text"]
+notes = "http(s):// and www. addresses; already-linked text skipped"
+
+[[feature]]
+id = "edit.remove-all-links"
+title = "Remove all links"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.remove_links"]
+tools = ["links_remove"]
+evidence = [
+ "crates/engine/src/tests.rs::links_from_urls_and_link_edits",
+ "crates/automation/tests/automation.rs::links_through_tools",
+ "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed",
+ "crates/ui-egui/tests/ui_group/pdf_links.rs::removing_all_links_and_undo",
+ "crates/ui-egui/tests/ui_group/pdf_links.rs::the_link_context_menu_shows_only_for_a_right_click_on_a_link",
+]
+notes = "removes all link annotations across the document or on specified pages (links_remove pages); a right-click on a link offers Properties, Delete and Remove all links; undoable, with a toast giving the count"
+
+[[feature]]
+id = "edit.action-goto"
+title = "Go-to-page-view action (with named destinations)"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "partial"
+commands = ["edit.link"]
+tools = ["link_add"]
+evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links"]
+notes = "go to a page (Fit view); named destinations and zoom pending"
+
+[[feature]]
+id = "edit.action-uri"
+title = "Open-a-web-link action"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.link"]
+tools = ["link_add"]
+evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools", "crates/ui-egui/tests/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/document_links.rs::a_button_uri_action_asks_first", "crates/ui-egui/tests/document_links.rs::an_email_link_cannot_attach_a_local_file", "crates/ui-egui/tests/document_links.rs::a_lookalike_host_is_shown_in_punycode_and_flagged"]
+notes = "Opening a document's link or button URI asks first; only http, https and mailto addresses are offered (#90, #91), and an email address that asks the email app to attach or insert a local file is refused. The prompt shows the host as the browser receives it: international hosts in punycode, lookalikes that mix alphabets flagged, numeric hosts in their usual form."
+
+[[feature]]
+id = "edit.action-launch"
+title = "Open-a-file action"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.action-named"
+title = "Execute-a-menu-item action (safe list)"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.action-set-ocg-state"
+title = "Set-layer-visibility action"
+area = "C"
+tier = "P2"
+milestone = "M12"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/ui_group/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/ui_group/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"]
+notes = "Links and push buttons run it: ON, OFF and Toggle in order, by optional content group, and with /PreserveRB (the default) a layer turned on turns off the rest of its /RBGroups group. Like the Layers panel it changes the view, not the document (no undo step, not saved). Not yet: creating the action (Link Properties, a field's Actions), bookmarks and other triggers, /Next chains, JavaScript ocg.state, and the Layers panel keeping radio-button groups itself."
+
+[[feature]]
+id = "edit.action-show-hide-field"
+title = "Show/hide-a-field action"
+area = "C"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/buttons.rs::hide_actions_hide_and_show_fields", "crates/forms/src/tests.rs::push_buttons_read_hide_actions"]
+notes = "A push button's Hide action (/T names, field or widget references, arrays; /H) hides or shows the fields and their kids; hidden widgets take no clicks, tab stops or highlights. A widget reference acts on its whole field, and only form fields are hidden, not other annotations."
+
+[[feature]]
+id = "edit.action-javascript"
+title = "Run-a-JavaScript action"
+area = "C"
+tier = "P1"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.action-thread"
+title = "Read-an-article action"
+area = "C"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.action-goto-embedded"
+title = "Go to a target in an embedded file (GoToE)"
+area = "C"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.action-media"
+title = "Play sound / media / rich-media actions"
+area = "C"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.header-footer-add"
+title = "Add header and footer"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.header_footer"]
+tools = ["doc_header_footer"]
+evidence = ["crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
+notes = "Helvetica only (no font choice yet); drawn in display space on rotated pages; artifact-tagged"
+
+[[feature]]
+id = "edit.header-footer-update-remove"
+title = "Update or remove header and footer"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.header_footer.update", "edit.header_footer.remove"]
+tools = ["doc_header_footer", "doc_remove_marks"]
+evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"]
+notes = "recognises PdfCraft marks; Acrobat-made headers are not detected yet"
+
+[[feature]]
+id = "edit.header-footer-tokens"
+title = "Page number and date tokens in header and footer"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.header_footer"]
+tools = ["doc_header_footer"]
+evidence = ["crates/edit/src/tests.rs::tokens_expand", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
+notes = "<<1>>, <>, <<1 of n>>, <>, dates, Bates"
+
+[[feature]]
+id = "edit.header-footer-ranges"
+title = "Header and footer page ranges (all, range, odd, even)"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.header_footer"]
+tools = ["doc_header_footer"]
+evidence = ["crates/ui-egui/src/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"]
+notes = "all / from–to, all / even / odd"
+
+[[feature]]
+id = "edit.header-footer-shrink"
+title = "Shrink document to avoid overwriting content"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.watermark-add-text"
+title = "Add a text watermark"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.watermark"]
+tools = ["doc_watermark"]
+evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/automation.rs::headers_watermarks_and_backgrounds_through_tools"]
+notes = "Helvetica; fit-to-page or a size"
+
+[[feature]]
+id = "edit.watermark-add-file"
+title = "Add a watermark from an image or PDF page"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.watermark"]
+tools = ["doc_watermark"]
+evidence = ["crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files"]
+notes = "an image or a page of a PDF, scaled relative to the page, rotated, with opacity, behind or on top"
+
+[[feature]]
+id = "edit.watermark-update-remove"
+title = "Update or remove watermarks"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.watermark.update", "edit.watermark.remove"]
+tools = ["doc_watermark", "doc_remove_marks"]
+evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
+notes = ""
+
+[[feature]]
+id = "edit.watermark-appearance"
+title = "Watermark rotation, opacity, scale, position, print/screen visibility"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "partial"
+commands = ["edit.watermark"]
+tools = ["doc_watermark"]
+evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/marks_ui.rs::the_watermark_edit_carries_the_offset"]
+notes = "rotation, opacity, behind/on top, and a horizontal/vertical offset from the page centre in points (dialog and offset_x/offset_y); no offset from the page edge or in page units, and no print/screen-only variants yet"
+
+[[feature]]
+id = "edit.background-add"
+title = "Add a page background (colour or file)"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.background"]
+tools = ["doc_background"]
+evidence = ["crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/ui_group/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"]
+notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page"
+
+[[feature]]
+id = "edit.background-update-remove"
+title = "Update or remove backgrounds"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.background.update", "edit.background.remove"]
+tools = ["doc_background", "doc_remove_marks"]
+evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
+notes = ""
+
+[[feature]]
+id = "edit.decoration-profiles"
+title = "Saved header/footer and watermark settings"
+area = "C"
+tier = "P2"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.bates-numbering"
+title = "Bates numbering"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "partial"
+commands = ["edit.bates", "edit.header_footer"]
+tools = ["doc_header_footer"]
+evidence = ["crates/edit/src/tests.rs::tokens_expand"]
+notes = "Bates ▸ Add opens Header & Footer with a Bates number; no multi-file batch yet"
+
+[[feature]]
+id = "edit.bates-multi-file"
+title = "Bates numbering across multiple files"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.bates-remove"
+title = "Remove Bates numbering"
+area = "C"
+tier = "P1"
+milestone = "M7"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.spell-check"
+title = "Spell check in comments, form fields and edited text"
+area = "C"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.spell-dictionaries"
+title = "Spelling dictionaries per language and a user dictionary"
+area = "C"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "edit.format-panel"
+title = "Format panel for edited text"
+area = "C"
+tier = "P0"
+milestone = "M7"
+status = "shipped"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
+notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open"
+
+[[feature]]
+id = "edit.adobe-express-edits"
+title = "Adobe Express image edits (remove background, generative fill)"
+area = "C"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "cloud-only; images open in an external editor instead"
+
+# ==================================================================================================
+# D. Organize pages and documents
+# ==================================================================================================
+
+[[feature]]
+id = "organize.organize-grid"
+title = "Organize pages thumbnail grid"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.organize"]
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"]
+notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned."
+
+[[feature]]
+id = "organize.multi-select-pages"
+title = "Select pages with click, ⌘-click and ⇧-click"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.organize"]
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_select_all_shortcut_preserves_current_page_and_sets_range_anchor", "crates/ui-egui/tests/control.rs::select_all_key_selects_every_page_in_organize", "crates/ui-egui/tests/editing.rs::organize_page_range_tracks_thumbnails_and_applies_typed_selection", "crates/ui-egui/tests/editing.rs::organize_page_range_rejects_invalid_input_and_escape_cancels_without_clearing", "crates/ui-egui/tests/control.rs::state_reports_the_selected_pages"]
+notes = "Ctrl+A / ⌘A selects every page in the Organize grid, preserving the current page; text inputs and dialogs keep their own keyboard input. Page checkboxes also toggle selection without modifiers (organize_checkboxes_toggle_multiple_pages_without_modifiers). An editable physical page-range list stays synchronized with thumbnails; Enter, focus loss or Apply commits valid input, Escape cancels, and invalid input preserves the selection. UI control select accepts the same bounded ranges."
+
+[[feature]]
+id = "organize.rotate-pages"
+title = "Rotate pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.rotate", "page.rotate_ccw"]
+tools = ["page_rotate"]
+evidence = ["crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions"]
+notes = ""
+
+[[feature]]
+id = "organize.rotate-filters"
+title = "Rotate by range with even/odd and orientation filters"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.rotate_dialog"]
+tools = ["page_rotate"]
+evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
+notes = "Rotate Pages: all/selection/range; even/odd; landscape/portrait"
+
+[[feature]]
+id = "organize.delete-pages"
+title = "Delete pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.delete"]
+tools = ["page_delete"]
+evidence = ["crates/organize/src/tests.rs::delete_keeps_inherited_attributes_on_survivors", "crates/organize/src/tests.rs::cannot_delete_every_page_or_missing_pages", "crates/ui-egui/tests/editing.rs::delete_is_refused_when_it_would_remove_every_page"]
+notes = ""
+
+[[feature]]
+id = "organize.move-pages"
+title = "Move pages earlier or later"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = ["page_move"]
+evidence = ["crates/organize/src/tests.rs::move_pages_reorders", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it"]
+notes = ""
+
+[[feature]]
+id = "organize.drag-reorder"
+title = "Reorder pages by drag and drop"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::dragging_thumbnails_reorders_pages"]
+notes = "drag thumbnails (or the selection) in the page grid; an insertion bar shows where they go; move earlier/later buttons too"
+
+[[feature]]
+id = "organize.insert-blank"
+title = "Insert blank pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.insert_blank"]
+tools = ["page_insert_blank"]
+evidence = ["crates/organize/src/tests.rs::inserts_blank_page", "crates/ui-egui/tests/editing.rs::insert_blank_page_after_selection", "crates/engine/src/tests.rs::insert_blank_between_pages_keeps_both_neighbours"]
+notes = ""
+
+[[feature]]
+id = "organize.insert-from-file"
+title = "Insert pages from another file"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.insert"]
+tools = ["page_insert_file"]
+evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/organize/src/tests.rs::inserting_a_file_brings_its_bookmarks_under_one_named_after_it", "crates/automation/tests/automation.rs::inserting_a_file_brings_its_bookmarks", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"]
+notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The file's bookmarks come along, collapsed under one named after the file, placed among the document's bookmarks where the pages went (as Combine does per file). The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app; the file's tags (structure tree) are not merged."
+
+[[feature]]
+id = "organize.insert-from-clipboard"
+title = "Insert pages from the clipboard"
+area = "D"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.insert-from-scanner"
+title = "Insert pages from a scanner"
+area = "D"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.insert-from-web"
+title = "Insert pages from a web page"
+area = "D"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.insert-by-drop"
+title = "Insert by dropping files onto the page grid"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "partial"
+commands = ["page.organize"]
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::files_dropped_between_pages_are_inserted_there", "crates/ui-egui/tests/editing.rs::files_dropped_before_the_pointer_is_known_wait_for_it", "crates/ui-egui/tests/editing.rs::files_dropped_outside_the_page_grid_still_open_as_documents"]
+notes = "Desktop: PDFs, images and .txt files dropped on the Organize page grid are inserted at the gap under the pointer, in order, and selected (elsewhere a dropped file still opens as a document). While the files hover, a bar at the gap and a + badge follow the pointer; on macOS the pointer is read from AppKit, because the windowing layer sends no moves during a file drag. Missing: on Windows and Linux there is no live position, so the grid is only outlined and the files go to the gap the pointer is at right after the drop (or the end after a second); the web build (dropped files open as documents). Covered by simulated drop events; a real drag from Finder is not in the automated tests."
+
+[[feature]]
+id = "organize.extract-pages"
+title = "Extract pages to a new document"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.extract"]
+tools = ["page_extract"]
+evidence = ["crates/ui-egui/tests/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"]
+notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied."
+
+[[feature]]
+id = "organize.extract-separate-files"
+title = "Extract pages as separate files"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.extract"]
+tools = ["page_extract"]
+evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/ui-egui/tests/pickers.rs::extract_pages_saves_under_the_file_name_typed_in_the_dialog"]
+notes = "The dialog has a File name field (#737), filled in with the document's name: pages are written as ` (page N).pdf` into the chosen folder, and one renamed page as `.pdf`. Extracting into a new tab names the tab (and so Save As) after it."
+
+[[feature]]
+id = "organize.extract-and-delete"
+title = "Delete pages after extracting"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.extract"]
+tools = ["page_extract"]
+evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options"]
+notes = "undoable"
+
+[[feature]]
+id = "organize.replace-pages"
+title = "Replace pages with pages from another file"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.replace"]
+tools = ["page_replace"]
+evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"]
+notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept"
+
+[[feature]]
+id = "organize.duplicate-pages"
+title = "Duplicate pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.duplicate"]
+tools = ["page_duplicate"]
+evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last"]
+notes = "copies share fonts and images with the originals"
+
+[[feature]]
+id = "organize.reverse-pages"
+title = "Reverse page order"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.cut-copy-paste-pages"
+title = "Cut, copy and paste pages"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.copy", "page.cut", "page.paste"]
+tools = ["page_insert_file"]
+evidence = ["crates/ui-egui/tests/editing.rs::copying_cutting_and_pasting_pages"]
+notes = "⌘C/⌘X/⌘V and the thumbnail menu in Organize Pages; paste works across documents"
+
+[[feature]]
+id = "organize.drag-pages-between-documents"
+title = "Drag pages between documents"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.page-range-selection"
+title = "Select by range, odd/even, landscape/portrait"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "partial"
+commands = ["page.organize"]
+tools = ["page_rotate"]
+evidence = ["crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
+notes = "Organize ▸ Select: all, odd, even, landscape, portrait; ranges via the page tools"
+
+[[feature]]
+id = "organize.thumbnail-size"
+title = "Thumbnail size slider"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "shipped"
+commands = ["page.organize"]
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn"]
+notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. Missing: a slider as in Acrobat; the size is per document view and not remembered between sessions; the Pages side panel has no size control."
+
+[[feature]]
+id = "organize.split-every-n"
+title = "Split every N pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.split"]
+tools = ["doc_split"]
+evidence = ["crates/ui-egui/tests/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"]
+notes = "Each part carries the PDF/X output intent and identification (#263), as Extract does."
+
+[[feature]]
+id = "organize.split-before-pages"
+title = "Split before chosen pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.split"]
+tools = ["doc_split"]
+evidence = ["crates/ui-egui/tests/editing.rs::split_before_selected_pages"]
+notes = ""
+
+[[feature]]
+id = "organize.split-by-size"
+title = "Split by file size"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.split"]
+tools = ["doc_split"]
+evidence = ["crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
+notes = "greedy by single-page size (an over-estimate: parts stay under the limit)"
+
+[[feature]]
+id = "organize.split-by-bookmarks"
+title = "Split by top-level bookmarks"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.split"]
+tools = ["doc_split"]
+evidence = ["crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"]
+notes = "files named after the bookmarks"
+
+[[feature]]
+id = "organize.split-naming"
+title = "Name split parts after bookmarks; never overwrite"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.split-multiple-files"
+title = "Split multiple files in one run"
+area = "D"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.combine-files"
+title = "Combine files into one PDF"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.combine"]
+tools = ["doc_combine"]
+evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"]
+notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed."
+
+[[feature]]
+id = "organize.combine-bookmarks"
+title = "Combine adds a bookmark per file with its own bookmarks nested"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.combine"]
+tools = ["doc_combine"]
+evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments", "crates/organize/src/tests.rs::a_file_split_around_another_is_copied_once_with_one_bookmark", "crates/organize/src/tests.rs::grouped_runs_keep_their_order_repeats_and_separate_files", "crates/engine/src/tests.rs::a_file_split_around_another_combines_in_order_with_one_bookmark", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/automation/tests/automation.rs::combine_extract_and_split"]
+notes = "Also when a file's pages are split around other files' pages (Combine files grid): the file is copied once, so it keeps one bookmark at its first page, its nested bookmarks, links between its own pages, its form fields and one copy of its attachments. doc_combine does the same with groups."
+
+[[feature]]
+id = "organize.combine-choose-pages"
+title = "Choose which pages of each file to combine"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.combine"]
+tools = ["doc_combine"]
+evidence = ["crates/ui-egui/tests/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed", "crates/automation/tests/automation.rs::combine_extract_and_split"]
+notes = "A page range per file (such as 1-3, 6; in the order given), checked as it is typed, with the page count it takes; bookmarks to left-out pages lose their destination"
+
+[[feature]]
+id = "organize.combine-sort"
+title = "Sort and reorder files before combining"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "shipped"
+commands = ["page.combine"]
+tools = ["doc_combine"]
+evidence = ["crates/ui-egui/tests/editing.rs::column_headings_sort_the_list_and_flip_on_the_second_click", "crates/ui-egui/tests/editing.rs::ctrl_and_shift_clicks_select_several_files_that_move_and_go_together", "crates/ui-egui/tests/editing.rs::undo_and_redo_restore_the_combine_list", "crates/automation/tests/automation.rs::combine_extract_and_split"]
+notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag (rows in the list, cards in the grid); every change undoable. doc_combine combines in the order its paths are given."
+
+[[feature]]
+id = "organize.combine-thumbnails"
+title = "Thumbnail (grid) view of the files before combining"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "shipped"
+commands = ["page.combine"]
+tools = ["doc_combine"]
+evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"]
+notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 200% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; kept in the settings, --combine-zoom 60..200), keeping the card under the pointer in place. Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 768 px; at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file."
+
+[[feature]]
+id = "organize.combine-options"
+title = "Combine options (file size, convert to PDF/A, include open files)"
+area = "D"
+tier = "P2"
+milestone = "M11"
+status = "partial"
+commands = ["page.combine"]
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::open_documents_can_be_added_to_combine"]
+notes = "Include open files: Add open documents (with their unsaved changes). Missing: file-size options and converting the result to PDF/A."
+
+[[feature]]
+id = "organize.resource-dedupe"
+title = "Store identical fonts and images once when combining or inserting"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["page.combine", "page.insert"]
+tools = ["doc_combine"]
+evidence = ["crates/organize/src/tests.rs::combine_and_insert_share_identical_resources", "crates/organize/src/dedupe.rs::merges_bottom_up_and_keeps_identity_objects"]
+notes = ""
+
+[[feature]]
+id = "organize.preserve-links-dests"
+title = "Rewire links and named destinations to copied pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/organize/src/tests.rs::links_are_rewired_to_copied_pages_or_dropped", "crates/organize/src/tests.rs::named_destinations_are_resolved_to_the_copies"]
+notes = ""
+
+[[feature]]
+id = "organize.preserve-fields"
+title = "Keep form fields interactive when copying pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/organize/src/tests.rs::form_fields_come_along_and_are_registered"]
+notes = ""
+
+[[feature]]
+id = "organize.preserve-layers"
+title = "Keep layers and their default state when copying pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/organize/src/tests.rs::layers_are_registered_with_their_default_state"]
+notes = ""
+
+[[feature]]
+id = "organize.preserve-inherited"
+title = "Copy inherited page attributes with pages"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/organize/src/tests.rs::inherited_attributes_travel_with_copied_pages"]
+notes = ""
+
+[[feature]]
+id = "organize.preserve-attachments"
+title = "Keep attachments when combining"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments"]
+notes = ""
+
+[[feature]]
+id = "organize.field-name-conflicts"
+title = "Rename or merge colliding field names when combining"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.insert", "page.combine"]
+tools = ["doc_combine"]
+evidence = ["crates/organize/src/tests.rs::import_renames_a_colliding_field_name", "crates/organize/src/tests.rs::import_renames_to_the_first_free_variant", "crates/organize/src/tests.rs::import_renames_away_from_an_orphaned_widgets_name", "crates/organize/src/tests.rs::import_renames_a_nested_field_whose_full_name_collides", "crates/organize/src/tests.rs::import_renames_the_colliding_child_not_the_whole_tree", "crates/organize/src/tests.rs::import_renames_a_field_colliding_with_another_incoming_field", "crates/organize/src/tests.rs::import_can_merge_into_same_named_fields_instead", "crates/organize/src/tests.rs::a_renamed_field_tree_survives_a_save_and_reopen", "crates/organize/src/tests.rs::a_repeated_page_joins_the_renamed_field", "crates/organize/src/tests.rs::combine_renames_colliding_field_names_and_reports_them", "crates/organize/src/tests.rs::combine_grouped_renames_colliding_field_names", "crates/organize/src/tests.rs::combine_field_conflicts_previews_each_file_in_order", "crates/engine/src/tests.rs::insert_from_file_renames_colliding_fields_and_notes_it", "crates/engine/src/tests.rs::combine_reports_renamed_fields_and_can_merge", "crates/automation/tests/automation.rs::combine_renames_colliding_field_names", "crates/ui-egui/tests/editing.rs::combine_list_warns_that_colliding_field_names_will_be_renamed"]
+notes = "Collisions are found by fully-qualified name (parent.T.child), counting names that hang only off page widgets even when /AcroForm never lists them, and also between two incoming fields. The colliding field's own /T takes the first free variant (Text1-1, Text1-2, …), children keep their place, and nothing is silent: inserting into an open document notes each rename in the repair log (warning that scripts referring to the old names were not rewritten) and the undo step's label counts them; doc_combine lists renamed_fields in its result; the Combine tab's Warnings column previews the renames per file before combining. field_conflicts \"merge\" on doc_combine (and the engine policy) keeps the older one-field behaviour. Still missing: the Combine tab's preview considers whole files, not a narrowed page range; the UI offers no merge choice (it always renames); Create ▸ Multiple files renames without reporting; scripts and XFA bindings that reference old names are never rewritten; Acrobat's behaviour not observed."
+
+[[feature]]
+id = "organize.struct-tree-merge"
+title = "Merge structure trees when combining"
+area = "D"
+tier = "P1"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.page-labels-edit"
+title = "Edit page labels (style, prefix, start)"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.number"]
+tools = ["page_number"]
+evidence = ["crates/organize/src/tests.rs::number_pages_like_acrobat", "crates/engine/src/tests.rs::number_pages_shows_in_the_viewer_and_undoes", "crates/ui-egui/tests/bookmarks.rs::number_pages_dialog_labels_the_selected_pages", "crates/automation/tests/automation.rs::numbering_pages_through_tools"]
+notes = ""
+
+[[feature]]
+id = "organize.page-boxes"
+title = "Set page boxes (crop, trim, bleed, art)"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.boxes"]
+tools = ["page_set_box"]
+evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::set_page_boxes_dialog_applies_margins_to_the_chosen_pages", "crates/organize/src/tests.rs::page_boxes_default_inherit_and_set"]
+notes = "crop/trim/bleed/art/media, margins in pt/in/mm, page ranges, preview; no Remove white margins yet"
+
+[[feature]]
+id = "organize.crop-tool"
+title = "Crop pages by dragging a rectangle"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["page.crop"]
+tools = ["page_set_box"]
+evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select"]
+notes = "drag to crop; double-click opens Set Page Boxes"
+
+[[feature]]
+id = "organize.remove-white-margins"
+title = "Remove white margins"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.change-page-size"
+title = "Change page size"
+area = "D"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.page-transitions"
+title = "Page transitions"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "partial"
+commands = []
+tools = ["doc_info", "page_transition_set"]
+evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/automation/tests/automation.rs::page_transitions_are_set_and_removed_through_the_tools"]
+notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog"
+
+[[feature]]
+id = "organize.page-tab-order"
+title = "Page tab order property"
+area = "D"
+tier = "P1"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.page-actions"
+title = "Page open/close actions"
+area = "D"
+tier = "P2"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.page-templates"
+title = "Page templates"
+area = "D"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-add"
+title = "Add a bookmark (⌘B)"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["bookmark.add"]
+tools = ["bookmark_add"]
+evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"]
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-rename"
+title = "Rename bookmarks"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = ["bookmark_rename"]
+evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/bookmarks.rs::escape_cancels_a_rename", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"]
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-delete"
+title = "Delete bookmarks"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = ["bookmark_delete"]
+evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save"]
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-move"
+title = "Reorder and nest bookmarks"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = ["bookmark_move"]
+evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"]
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-set-destination"
+title = "Set a bookmark's destination"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = ["bookmark_set_page"]
+evidence = ["crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"]
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-appearance"
+title = "Bookmark style and colour"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-actions"
+title = "Bookmark actions"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.bookmark-expand-collapse"
+title = "Expand and collapse all bookmarks"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["file.properties"]
+tools = []
+evidence = ["crates/ui-egui/tests/bookmarks.rs::expanding_and_collapsing_all_bookmarks"]
+notes = "Bookmarks panel options: expand all, expand top-level, collapse all"
+
+[[feature]]
+id = "organize.bookmarks-from-structure"
+title = "Generate bookmarks from headings or tags"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["bookmark.from_structure"]
+tools = ["bookmark_from_structure"]
+evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"]
+notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it"
+
+[[feature]]
+id = "organize.attachments-add"
+title = "Add file attachments"
+area = "D"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.attachments-delete"
+title = "Delete attachments"
+area = "D"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.attachments-description"
+title = "Edit attachment descriptions"
+area = "D"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.associated-files"
+title = "Associated files with relationships (PDF/A-3, PDF 2.0)"
+area = "D"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.portfolio-view"
+title = "View PDF Portfolios as a file list"
+area = "D"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.portfolio-extract"
+title = "Extract files from a portfolio"
+area = "D"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.portfolio-create"
+title = "Create PDF Portfolios"
+area = "D"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.document-properties"
+title = "Document properties dialog (description, fonts, advanced)"
+area = "D"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_info"]
+evidence = ["crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/editing.rs::cancelling_properties_discards_the_draft"]
+notes = ""
+
+[[feature]]
+id = "organize.edit-metadata"
+title = "Edit title, author, subject and keywords"
+area = "D"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_set_info"]
+evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step"]
+notes = "When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)."
+
+[[feature]]
+id = "organize.fonts-list"
+title = "List fonts with type and embedding status"
+area = "D"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_info"]
+evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"]
+notes = "name, type, embedding, subset and encoding; doc_info also reports the face standing in for a program the document does not embed and why it was chosen (Acrobat's Actual Font), including the case where no licensed face exists so nothing is drawn. Still missing: the Fonts tab in the interface does not show the substituted face yet, only the data layer does"
+
+[[feature]]
+id = "organize.initial-view"
+title = "Edit initial view (layout, magnification, panels, window options)"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_initial_view"]
+evidence = ["crates/ui-egui/tests/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"]
+notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)"
+
+[[feature]]
+id = "organize.custom-properties"
+title = "Custom document properties"
+area = "D"
+tier = "P2"
+milestone = "M4"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.xmp-editor"
+title = "Additional metadata (XMP) editor"
+area = "D"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.document-language"
+title = "Set document language and binding"
+area = "D"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_initial_view"]
+evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/automation.rs::initial_view_through_tools"]
+notes = "Advanced ▸ Reading Options: binding and language"
+
+[[feature]]
+id = "organize.print-presets"
+title = "Print dialog presets in document properties"
+area = "D"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "organize.base-url"
+title = "Base URL for relative links"
+area = "D"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+# ==================================================================================================
+# E. Comments and review
+# ==================================================================================================
+
+[[feature]]
+id = "comment.list-view"
+title = "Comments panel with threaded replies (read-only)"
+area = "E"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_list"]
+evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::page_groups_collapse_and_expand"]
+notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet"
+
+[[feature]]
+id = "comment.appearance-generation"
+title = "Generate correct appearances for every markup type"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "partial"
+commands = []
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn"]
+notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets); rich text not yet"
+
+[[feature]]
+id = "comment.sticky-note"
+title = "Sticky note comments"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.note"]
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
+notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline"
+
+[[feature]]
+id = "comment.highlight"
+title = "Highlight text"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.highlight"]
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/comments.rs::a_new_highlight_opens_its_note_for_typing", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
+notes = "drag over text, or pick the tool with text selected; a new highlight opens its note for typing in the Comments panel; agents can highlight by text search"
+
+[[feature]]
+id = "comment.underline"
+title = "Underline text"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.underline"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked"]
+notes = ""
+
+[[feature]]
+id = "comment.strikeout"
+title = "Strikethrough text"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.strikeout"]
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
+notes = ""
+
+[[feature]]
+id = "comment.squiggly"
+title = "Squiggly underline"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.squiggly"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::the_squiggly_tool_marks_selected_text"]
+notes = "Highlight ▸ Squiggly underline (with Underline and Strikethrough), the agent tool and the engine"
+
+[[feature]]
+id = "comment.replace-text"
+title = "Replace-text proposal (strikeout + caret)"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.replace"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
+notes = "strikeout (/IT StrikeOutTextEdit) and a grouped caret (/RT /Group) with the replacement"
+
+[[feature]]
+id = "comment.insert-text"
+title = "Insert-text proposal (caret)"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "partial"
+commands = ["comment.caret"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/comments.rs::inserted_text_snaps_to_the_gap_between_characters_on_the_baseline", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
+notes = "in the UI the caret snaps to the nearer character edge, on the baseline and sized to the text, on upright horizontal text of unrotated pages; elsewhere it stays at the click. comment_add places it where it is told (no snapping)"
+
+[[feature]]
+id = "comment.typewriter"
+title = "Add text comment (typewriter)"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["sign.fill.text"]
+tools = ["fill_sign_add"]
+evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/automation.rs::fill_sign_text_font_size_is_saved_and_validated"]
+notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates."
+
+[[feature]]
+id = "comment.text-box"
+title = "Text box comment with rich text"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "partial"
+commands = ["comment.freetext"]
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"]
+notes = "Helvetica text with colour, size and alignment; /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet"
+
+[[feature]]
+id = "comment.callout"
+title = "Callout comment"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.callout"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
+notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text"
+
+[[feature]]
+id = "comment.pencil"
+title = "Freehand drawing (ink)"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.ink"]
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::ink_strokes_are_smooth_curves_through_their_points", "crates/annot/src/tests.rs::drawings_resize_by_scaling_their_strokes"]
+notes = "one ink annotation per stroke; strokes are drawn as smooth curves through their points, as in Acrobat; resize with the 8 handles (the strokes scale into the new box)"
+
+[[feature]]
+id = "comment.eraser"
+title = "Eraser for ink strokes"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.eraser"]
+tools = []
+evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing"]
+notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step"
+
+[[feature]]
+id = "comment.line"
+title = "Line"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.line"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
+notes = ""
+
+[[feature]]
+id = "comment.arrow"
+title = "Arrow"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.arrow"]
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"]
+notes = ""
+
+[[feature]]
+id = "comment.rectangle"
+title = "Rectangle"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.square"]
+tools = ["comment_add", "comment_edit"]
+evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool"]
+notes = "resize with the 8 handles, straight after drawing with the tool still on (#2338); fill colour or no fill (#686) from the quick bar, Properties and comment_edit `fill`; fill swatches are the comment palette only (no custom colour picker yet)"
+
+[[feature]]
+id = "comment.oval"
+title = "Oval"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.circle"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
+notes = ""
+
+[[feature]]
+id = "comment.polygon"
+title = "Polygon"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.polygon"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
+notes = "click the corners; Enter, double-click or the first point finishes"
+
+[[feature]]
+id = "comment.polyline"
+title = "Connected lines (polyline)"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.polyline"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
+notes = ""
+
+[[feature]]
+id = "comment.cloud"
+title = "Cloud shape"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.cloud"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
+notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud)"
+
+[[feature]]
+id = "comment.area-highlight"
+title = "Area highlight"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.highlight"]
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::highlighting_an_area_off_the_text"]
+notes = "drag the highlighter off text to highlight a box"
+
+[[feature]]
+id = "comment.attach-file-comment"
+title = "Attach a file as a comment"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.attach"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
+notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel"
+
+[[feature]]
+id = "comment.record-audio"
+title = "Sound comments"
+area = "E"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.image-comment"
+title = "Add an image as a comment"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.line-endings"
+title = "All ten line-ending styles and dashed/cloudy borders"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "partial"
+commands = []
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::every_line_ending_is_drawn", "crates/automation/tests/automation.rs::line_endings_through_the_tool", "crates/ui-egui/tests/comments.rs::line_ending_properties_change_and_undo"]
+notes = "All ten /LE styles (None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash) are drawn on lines, polylines and callouts. comment_add endings and Comment properties set them. Dashed borders are drawn. Cloudy borders are still polygon-only."
+
+[[feature]]
+id = "comment.properties-dialog"
+title = "Comment properties (appearance, author, subject)"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_edit"]
+evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"]
+notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)"
+
+[[feature]]
+id = "comment.properties-bar"
+title = "Comment properties bar"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.note"]
+tools = ["comment_edit"]
+evidence = ["crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/ui-egui/tests/comments.rs::the_opacity_set_for_a_tool_goes_into_its_new_comments", "crates/ui-egui/tests/comments.rs::the_quick_bar_colour_changes_the_selected_comment", "crates/ui-egui/tests/comments.rs::the_quick_bar_colour_changes_the_selected_arrow"]
+notes = "quick-bar colour swatches immediately recolour a selected comment made with the active tool, and set the tool default; fill colour (rectangle, oval, polygon, cloud), opacity (10–100 %) and line thickness per tool; colour from the context menu"
+
+[[feature]]
+id = "comment.lock-comment"
+title = "Lock comments"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = []
+tools = ["comment_lock"]
+evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
+notes = "Comment Properties ▸ Locked; locked comments refuse move, resize, restyle and delete; text stays editable"
+
+[[feature]]
+id = "comment.default-properties"
+title = "Make current properties the tool default"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = []
+evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default", "crates/ui-egui/tests/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"]
+notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)"
+
+[[feature]]
+id = "comment.author-identity"
+title = "Author name from identity preferences"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = []
+tools = ["comment_add"]
+evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/ui_group/identity.rs::the_identity_name_signs_new_comments_and_is_remembered"]
+notes = "Preferences ▸ Identity: the name on new comments (default the login name, as Acrobat), remembered across restarts"
+
+[[feature]]
+id = "comment.reply"
+title = "Reply to comments"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_reply"]
+evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"]
+notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted"
+
+[[feature]]
+id = "comment.set-status"
+title = "Set review status (accepted, rejected, cancelled, completed)"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_set_status"]
+evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone"]
+notes = "status replies (/State, /StateModel Review), shown as a badge"
+
+[[feature]]
+id = "comment.checkmark"
+title = "Mark comments with a checkmark"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = []
+tools = ["comment_mark"]
+evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
+notes = "hidden /StateModel /Marked reply; card toggle and context menu"
+
+[[feature]]
+id = "comment.delete-comment"
+title = "Delete comments"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_delete"]
+evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"]
+notes = "deletes the pop-up and the reply thread"
+
+[[feature]]
+id = "comment.edit-comment-text"
+title = "Edit comment text"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_edit"]
+evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"]
+notes = "double-click a comment or card, or Edit in the menus; Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)"
+
+[[feature]]
+id = "comment.copy-comment-text"
+title = "Copy comment text"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize"]
+notes = "card and canvas context menus"
+
+[[feature]]
+id = "comment.filter-comments"
+title = "Filter comments (type, author, status, colour, checkmark)"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_list"]
+evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts", "crates/ui-egui/tests/comments.rs::the_panel_filters_by_colour_and_checkmark"]
+notes = "type, author, status, colour and checkmark"
+
+[[feature]]
+id = "comment.sort-comments"
+title = "Sort comments (page, author, date, type, colour)"
+area = "E"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_list"]
+evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts"]
+notes = "page, author, date, type, colour, with group headers"
+
+[[feature]]
+id = "comment.search-comments"
+title = "Search comments"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "partial"
+commands = ["comment.list"]
+tools = ["comment_list"]
+evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"]
+notes = "search by author, text and type in the panel; not yet in the Find bar"
+
+[[feature]]
+id = "comment.show-hide-comments"
+title = "Show or hide all comments and pop-ups"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "partial"
+commands = ["comment.hide_all"]
+tools = ["comments_hide"]
+evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
+notes = "hide/show all comments (fields and links still draw); per-type hiding not yet"
+
+[[feature]]
+id = "comment.popups"
+title = "Open and reposition comment pop-ups"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.connector-lines"
+title = "Connector lines between markups and pop-ups"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.review-history"
+title = "Review history of state changes"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.list"]
+tools = ["comment_list"]
+evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author"]
+notes = "status changes listed in Comment Properties"
+
+[[feature]]
+id = "comment.stamps-standard"
+title = "Standard business stamps"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.stamp"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
+notes = "12 standard business stamps in PdfCraft's own design; ISO 32000 stamp names where defined. A stamp placed on a rotated page reads upright as the page is displayed (#1398)"
+
+[[feature]]
+id = "comment.stamps-sign-here"
+title = "Sign-here stamps"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.stamp"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
+notes = "Accepted, Initial Here, Rejected, Sign Here, Witness as pointed tags. The point stays on the left as the page is displayed, including /Rotate 90, 180 and 270 (#1398)"
+
+[[feature]]
+id = "comment.stamps-dynamic"
+title = "Dynamic stamps (name and date)"
+area = "E"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["comment.stamp"]
+tools = ["comment_add"]
+evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
+notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398)"
+
+[[feature]]
+id = "comment.stamps-custom"
+title = "Custom stamps from images or PDF pages"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.stamp"]
+tools = ["stamp_custom"]
+evidence = ["crates/ui-egui/tests/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::explicit_image_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_keep_displayed_orientation_on_rotated_pages"]
+notes = "Create custom stamp from a PDF page or an image (category and name), kept in the app's settings; placed at natural size (at most 200 pt) keeping its shape; resizable (appearance redrawn from /PCPicture); delete from the palette. Natural-size and explicit-rectangle images, and PDF-page stamps, read upright on a rotated page (#1398)"
+
+[[feature]]
+id = "comment.stamps-manage"
+title = "Manage stamp categories and imports"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.stamps-rotate"
+title = "Rotate stamps with a handle"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.import-fdf-xfdf"
+title = "Import comments from FDF/XFDF"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.import"]
+tools = ["doc_import_data"]
+evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
+notes = "replies, pop-ups, ink, quads, line ends; same /NM replaces"
+
+[[feature]]
+id = "comment.export-fdf-xfdf"
+title = "Export all comments to FDF/XFDF"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.export"]
+tools = ["doc_export_data"]
+evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
+notes = ""
+
+[[feature]]
+id = "comment.export-selected"
+title = "Export selected comments"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.import-from-pdf"
+title = "Import comments from another PDF"
+area = "E"
+tier = "P2"
+milestone = "M5"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.summarize"
+title = "Summarize comments into a new PDF"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "partial"
+commands = ["comment.summarize"]
+tools = ["comments_summarize"]
+evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"]
+notes = "Comments only layout, sorted by page, author, date or type; the layouts with the document and connector lines not yet"
+
+[[feature]]
+id = "comment.flatten"
+title = "Flatten comments into page content"
+area = "E"
+tier = "P1"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.flatten"]
+tools = ["doc_flatten"]
+evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"]
+notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted"
+
+[[feature]]
+id = "comment.migrate"
+title = "Migrate comments to a revised document"
+area = "E"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.measure-distance"
+title = "Distance measuring tool"
+area = "E"
+tier = "P2"
+milestone = "M12"
+status = "partial"
+commands = ["measure.distance"]
+tools = ["measure_distance"]
+evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
+notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested."
+
+[[feature]]
+id = "comment.measure-perimeter"
+title = "Perimeter measuring tool"
+area = "E"
+tier = "P2"
+milestone = "M12"
+status = "shipped"
+commands = ["measure.perimeter"]
+tools = ["measure_perimeter"]
+evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
+notes = "Connected-line lengths with click vertices and double-click/Enter finish. Repeat the first point to include a closing edge. Saved standard PolyLineDimension annotations."
+
+[[feature]]
+id = "comment.measure-area"
+title = "Area measuring tool"
+area = "E"
+tier = "P2"
+milestone = "M12"
+status = "shipped"
+commands = ["measure.area"]
+tools = ["measure_area"]
+evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
+notes = "Simple polygon areas, including concave boundaries and either winding. Self-intersecting boundaries are refused. Saved PolygonDimension annotations; no 3D surface measurement."
+
+[[feature]]
+id = "comment.measure-scale"
+title = "Scale ratio and calibration per viewport"
+area = "E"
+tier = "P2"
+milestone = "M12"
+status = "partial"
+commands = ["measure.scale"]
+tools = ["measure_scale"]
+evidence = ["crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip", "crates/measure/src/tests.rs::coordinates_roundtrip_for_crop_rotation_and_user_unit", "crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
+notes = "Persistent rectangular viewports with last-containing-viewport precedence, numeric scale and two-point calibration. First vertex selects the scale; prior annotations retain theirs. Partial: single-unit decimal rectilinear scales only; compound (ft-in), fractional and differing distance units can't be authored or imported, no viewport editing/removal UI, and interop with Acrobat's scale ratio dialog and its saved viewports is untested."
+
+[[feature]]
+id = "comment.measure-snap"
+title = "Snap to paths, endpoints, midpoints and intersections"
+area = "E"
+tier = "P2"
+milestone = "M12"
+status = "shipped"
+commands = ["measure.snap"]
+tools = ["measure_snap"]
+evidence = ["crates/automation/tests/automation.rs::measurement_snap_tool_covers_all_targets", "crates/measure/src/tests.rs::nested_forms_transforms_curves_and_cycles_are_bounded", "crates/measure/src/tests.rs::snap_paths_endpoints_midpoints_and_intersections"]
+notes = "Paths, endpoints, midpoints and intersections of painted vector geometry, including transformed Form XObjects. Cubics flattened to 0.2 user units. Bounded extraction and dense-intersection limits are reported. Raster/text outlines, clipping and optional-content visibility are not interpreted."
+
+[[feature]]
+id = "comment.measure-info-panel"
+title = "Measurement info panel"
+area = "E"
+tier = "P2"
+milestone = "M12"
+status = "shipped"
+commands = ["measure.info"]
+tools = ["measure_info", "measure_list"]
+evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
+notes = "Live value, units, X/Y deltas, angle and selected scale while drawing; saved measurements can be selected from the panel. Rectilinear 2D readings only."
+
+[[feature]]
+id = "comment.measure-export"
+title = "Export measurements as CSV"
+area = "E"
+tier = "P3"
+milestone = "M12"
+status = "shipped"
+commands = ["measure.export"]
+tools = ["measure_export"]
+evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::measurement_csv_quotes_newlines_and_blocks_formula_cells", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
+notes = "Atomic CSV export of page, annotation index, type, numeric value, unit, label, author and scale. Quotes embedded delimiters and prefixes spreadsheet formula triggers."
+
+[[feature]]
+id = "comment.geospatial"
+title = "Geospatial location and measuring"
+area = "E"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "comment.shared-review"
+title = "Send for comments / shared review"
+area = "E"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; exchange XFDF files or a pluggable review server instead"
+
+[[feature]]
+id = "comment.mentions-live-review"
+title = "@mentions, presence and live co-review"
+area = "E"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; no local equivalent"
+
+[[feature]]
+id = "comment.review-tracker"
+title = "Review tracker"
+area = "E"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud / legacy"
+
+[[feature]]
+id = "comment.enable-reader-commenting"
+title = "Enable commenting and measuring in Reader (usage rights)"
+area = "E"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "needs Adobe's private key; modern readers allow commenting anyway"
+
+# ==================================================================================================
+# F. Forms and JavaScript
+# ==================================================================================================
+
+[[feature]]
+id = "form.field-list"
+title = "Form fields panel listing every field and value"
+area = "F"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["form.fields"]
+tools = ["doc_info"]
+evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/ui_group/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/ui_group/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"]
+notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too"
+
+[[feature]]
+id = "form.highlight-fields"
+title = "Highlight existing fields"
+area = "F"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/view.rs::field_highlighting_is_remembered"]
+notes = "remembered for the next document and session; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it"
+
+[[feature]]
+id = "form.field-appearance-display"
+title = "Display checkbox, radio, list and signature widgets as designed"
+area = "F"
+tier = "P0"
+milestone = "M2"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/render/src/raster.rs::widget_appearance_states"]
+notes = ""
+
+[[feature]]
+id = "form.fill-text"
+title = "Fill text fields"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.fields"]
+tools = ["form_fill", "form_fields"]
+evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"]
+notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4)"
+
+[[feature]]
+id = "form.fill-checkbox-radio"
+title = "Toggle checkboxes and radio buttons (radios in unison)"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.fields"]
+tools = ["form_fill"]
+evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states"]
+notes = "NoToggleToOff honoured; widgets without appearances get our own"
+
+[[feature]]
+id = "form.fill-choice"
+title = "Choose from combo boxes and list boxes"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.fields"]
+tools = ["form_fill"]
+evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
+notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text"
+
+[[feature]]
+id = "form.push-buttons"
+title = "Push buttons run their actions"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "partial"
+commands = ["form.fields"]
+tools = []
+evidence = ["crates/ui-egui/tests/buttons.rs::buttons_reset_navigate_and_print", "crates/forms/src/af.rs::common_button_scripts_are_recognised", "crates/ui-egui/tests/javascript.rs::button_scripts_run_and_alert"]
+notes = "Reset form, Named (print, page navigation), URI, Go to, and any JavaScript (run in the sandboxed engine); submit explained, not sent"
+
+[[feature]]
+id = "form.field-navigation"
+title = "Tab between fields"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.fields"]
+tools = []
+evidence = ["crates/ui-egui/tests/forms.rs::tabbing_into_a_filled_field_selects_it_so_typing_replaces"]
+notes = "Tab / Shift+Tab move between text and choice fields in field order; entering with Tab selects the value"
+
+[[feature]]
+id = "form.appearance-regeneration"
+title = "Regenerate field appearances (auto-size, comb, multiline, password)"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "partial"
+commands = ["form.fields"]
+tools = ["form_fill", "form_set_props"]
+evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/prepare.rs::list_box_alignment_is_editable_and_preserved"]
+notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics"
+
+[[feature]]
+id = "form.rich-text-fields"
+title = "Rich-text field values"
+area = "F"
+tier = "P2"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.af-number"
+title = "Number and currency formatting (AFNumber)"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
+notes = "AFNumber_Format/Keystroke run natively: decimals, 5 separator styles, currency before/after, negative styles (red shown as minus/parentheses)"
+
+[[feature]]
+id = "form.af-percent"
+title = "Percentage formatting"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat"]
+notes = "AFPercent_Format/Keystroke"
+
+[[feature]]
+id = "form.af-date-time"
+title = "Date and time formatting and parsing"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
+notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused"
+
+[[feature]]
+id = "form.af-special"
+title = "Special formats (zip, phone, SSN, arbitrary mask)"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/af.rs::dates_times_and_masks"]
+notes = "zip, zip+4, phone, SSN, arbitrary masks (9 A O X)"
+
+[[feature]]
+id = "form.af-range-validate"
+title = "Range validation"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
+notes = "AFRange_Validate with Acrobat's messages"
+
+[[feature]]
+id = "form.af-simple-calculate"
+title = "Sum, product, average, min, max calculations"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
+notes = "SUM, PRD, AVG, MIN, MAX over fields and groups"
+
+[[feature]]
+id = "form.simplified-notation"
+title = "Simplified field notation calculations"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
+notes = "BVCALC expressions with + - * / and parentheses, escaped names"
+
+[[feature]]
+id = "form.custom-scripts"
+title = "Custom format, keystroke, validate and calculate scripts"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = ["form_set_script"]
+evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"]
+notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively."
+
+[[feature]]
+id = "form.calculation-order"
+title = "Calculation order"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
+notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form"
+
+[[feature]]
+id = "form.event-order"
+title = "Keystroke, validate, calculate, format event order"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = ["form_fill"]
+evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"]
+notes = "Keystroke (commit) \u2192 Validate \u2192 value \u2192 Calculate (in /CO order) \u2192 Format, for AF calls and custom scripts."
+
+[[feature]]
+id = "form.date-picker"
+title = "Date picker for date fields"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = []
+tools = ["form_fill"]
+evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar"]
+notes = "a month calendar under date fields; the pick is written in the field's own date format"
+
+[[feature]]
+id = "form.auto-complete"
+title = "Auto-complete from previous entries"
+area = "F"
+tier = "P2"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.required-fields"
+title = "Required-field highlighting"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = []
+tools = ["form_set_props"]
+evidence = ["crates/ui-egui/tests/view.rs::required_fields_get_a_red_border_when_highlighting"]
+notes = "required fields get a red border when fields are highlighted"
+
+[[feature]]
+id = "form.reset-form"
+title = "Clear or reset a form"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.clear"]
+tools = ["form_reset"]
+evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices"]
+notes = "back to /DV; undoable"
+
+[[feature]]
+id = "form.submit-form"
+title = "Submit form to a URL or email (with a prompt)"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.import-data"
+title = "Import form data (FDF, XFDF)"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.import_data"]
+tools = ["doc_import_data"]
+evidence = ["crates/xfdf/src/tests.rs::xfdf_import_keeps_text_around_xml_comments_and_instructions", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
+notes = "XFDF, FDF; XFDF text survives XML comments and processing instructions; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)"
+
+[[feature]]
+id = "form.export-data"
+title = "Export form data (FDF, XFDF)"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.export_data"]
+tools = ["doc_export_data"]
+evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
+notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)"
+
+[[feature]]
+id = "form.data-xml-csv-txt"
+title = "Form data as XML, CSV or tab-delimited text"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.export_data", "form.import_data"]
+tools = ["doc_export_data", "doc_import_data"]
+evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
+notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited"
+
+[[feature]]
+id = "form.merge-to-spreadsheet"
+title = "Merge form data files into a spreadsheet"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.merge_data"]
+tools = ["form_merge_data"]
+evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet"]
+notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file."
+
+[[feature]]
+id = "form.flatten-fields"
+title = "Flatten form fields"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.flatten"]
+tools = ["doc_flatten"]
+evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_a_valued_widget_it_cannot_draw", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"]
+notes = "widgets drawn into content and pruned from the field tree; a widget with no appearance to draw (none stored, or none for its selected state) stays a live field rather than losing its value, so a form with such fields is not fully flattened — Acrobat regenerates appearances first, which PdfCraft does not yet do here"
+
+[[feature]]
+id = "form.prepare-form-mode"
+title = "Prepare a form mode"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = ["form_add_field", "form_set_props", "form_delete_field"]
+evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"]
+notes = "fields shown with blue fill and name tags; select, move, resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits"
+
+[[feature]]
+id = "form.add-text-field"
+title = "Add text fields"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.text"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
+notes = "click for the default size or drag; named Text1, Text2…"
+
+[[feature]]
+id = "form.add-checkbox"
+title = "Add checkboxes"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.checkbox"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"]
+notes = "Check Box1…, Yes/Off appearance states"
+
+[[feature]]
+id = "form.add-radio"
+title = "Add radio buttons"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.radio"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
+notes = "new group Group1… or join the selected group with the next ChoiceN"
+
+[[feature]]
+id = "form.check-box-style"
+title = "Check box and radio button styles"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fields"]
+evidence = ["crates/forms/src/tests.rs::check_box_styles_change_the_mark", "crates/ui-egui/tests/prepare.rs::options_tab_sets_the_check_box_style", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
+notes = "Field Properties ▸ Options: check, circle, cross, diamond, square, star (/MK /CA, Acrobat's codes), drawn as paths; defaults check (check boxes) and circle (radio buttons)"
+
+[[feature]]
+id = "form.add-dropdown"
+title = "Add drop-down lists"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.combo"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
+notes = "Dropdown1…; items in Field Properties ▸ Options"
+
+[[feature]]
+id = "form.add-listbox"
+title = "Add list boxes"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.list"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
+notes = "List Box1…"
+
+[[feature]]
+id = "form.add-button"
+title = "Add push buttons"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.button"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
+notes = "Button1…; actions arrive with the JavaScript engine"
+
+[[feature]]
+id = "form.add-image-field"
+title = "Add image fields"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.image"]
+tools = ["form_add_field", "form_set_image"]
+evidence = ["crates/forms/src/tests.rs::image_fields_ask_for_a_picture_and_show_it", "crates/ui-egui/tests/prepare.rs::image_fields_take_a_picture_when_clicked", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
+notes = "icon-only push button with event.target.buttonImportIcon(), recognised natively; clicking asks for a PNG/JPEG/TIFF/GIF/BMP that becomes /MK /I, scaled proportionally and centred"
+
+[[feature]]
+id = "form.add-date-field"
+title = "Add date fields"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.date"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
+notes = "text field with AFDate_FormatEx(\"mm/dd/yyyy\") format/keystroke actions"
+
+[[feature]]
+id = "form.add-signature-field"
+title = "Add digital signature fields"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.add.signature"]
+tools = ["form_add_field"]
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"]
+notes = "unsigned /Sig field; signing is M9"
+
+[[feature]]
+id = "form.add-barcode-field"
+title = "Barcode fields (PDF417, QR, Data Matrix)"
+area = "F"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.props-general"
+title = "Field properties: general (name, tooltip, visibility, read-only, required)"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props"]
+evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/ui-egui/tests/ui_group/bulk_fields.rs::locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction", "crates/ui-egui/tests/ui_group/bulk_fields.rs::visiting_shared_property_tabs_without_changes_does_not_dirty_the_document"]
+notes = "name, tooltip, read only, required, locked; shared tooltip/flags are opt-in, mixed values preserved, failures keep the draft and roll back the batch (visibility pending)"
+
+[[feature]]
+id = "form.props-appearance"
+title = "Field properties: appearance (border, fill, font)"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props"]
+evidence = ["crates/forms/src/tests.rs::appearance_properties_restyle_every_widget", "crates/ui-egui/tests/prepare.rs::appearance_tab_restyles_the_field", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::partial_appearance_keeps_custom_default_appearance_and_font_resources", "crates/forms/src/tests.rs::partial_appearance_preserves_each_widgets_indirect_colours_and_border_extensions", "crates/automation/tests/form_bulk.rs::bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save"]
+notes = "border/fill colour, line width, solid/dashed/beveled/inset/underline, text colour, Helvetica/Times/Courier, font size; shared GUI and partial tool edits preserve untouched custom fonts, widget styles and unknown keys. Appearance regeneration still uses approximate metrics"
+
+[[feature]]
+id = "form.props-position"
+title = "Field properties: position and size"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props"]
+evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"]
+notes = "left/bottom/width/height in points; drag to move, corners to resize"
+
+[[feature]]
+id = "form.props-options"
+title = "Field properties: options per field type"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props"]
+evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"]
+notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
+
+[[feature]]
+id = "form.props-actions"
+title = "Field properties: actions per trigger"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = ["form_actions", "form_set_actions"]
+evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/prepare.rs::actions_tab_adds_and_removes_actions"]
+notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept."
+
+[[feature]]
+id = "form.props-format"
+title = "Field properties: format"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
+notes = "None/Number/Percentage/Date/Time/Special with a live example; writes Acrobat's AF scripts"
+
+[[feature]]
+id = "form.props-validate"
+title = "Field properties: validate"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
+notes = "range from/to"
+
+[[feature]]
+id = "form.props-calculate"
+title = "Field properties: calculate"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.field.properties"]
+tools = ["form_set_props", "form_fill"]
+evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
+notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending"
+
+[[feature]]
+id = "form.props-selection-change"
+title = "Field properties: selection change"
+area = "F"
+tier = "P2"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.props-signed"
+title = "Field properties: signed (lock fields when signed)"
+area = "F"
+tier = "P1"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.align-distribute-fields"
+title = "Align, centre, distribute and match field sizes"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = ["form_set_props"]
+evidence = ["crates/ui-egui/tests/prepare.rs::aligning_distributing_and_sizing_several_fields"]
+notes = "Shift/⌘-click to select several; right-click: Align (left, right, top, bottom, centres), Distribute, Set Fields to Same Size; the right-clicked field is the anchor"
+
+[[feature]]
+id = "form.duplicate-fields"
+title = "Duplicate fields across pages"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = []
+evidence = ["crates/forms/src/tests.rs::duplicating_a_field_across_pages_shares_its_value", "crates/ui-egui/tests/prepare.rs::duplicating_a_field_onto_every_page"]
+notes = "right-click a field ▸ Duplicate: all pages or a range; one field, shared value"
+
+[[feature]]
+id = "form.multiple-copies"
+title = "Create multiple copies of a field in a grid"
+area = "F"
+tier = "P2"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.rename-lock-fields"
+title = "Rename and lock fields"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = ["form_set_props"]
+evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"]
+notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in"
+
+[[feature]]
+id = "form.tab-order"
+title = "Tab order (row, column, structure, manual)"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["form.tab_order.row", "form.tab_order.column", "form.tab_order.structure"]
+tools = ["form_tab_order"]
+evidence = ["crates/forms/src/tests.rs::tab_order_follows_the_page_setting", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::ordering_tabs_manually", "crates/ui-egui/tests/prepare.rs::the_fields_panel_orders_tabs_manually"]
+notes = "rows, columns, structure/annotation order (page /Tabs), and manual order from the Fields panel while preparing a form; honoured by Tab"
+
+[[feature]]
+id = "form.show-tab-numbers"
+title = "Show tab numbers"
+area = "F"
+tier = "P2"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.preview-form"
+title = "Preview mode for testing a form"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.prepare"]
+tools = []
+evidence = ["crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"]
+notes = "Preview / Edit fields toggle in the Prepare a form panel"
+
+[[feature]]
+id = "form.auto-detect-fields"
+title = "Automatic form field detection"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "partial"
+commands = ["form.detect", "form.prepare"]
+tools = ["form_detect_fields"]
+evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"]
+notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups."
+
+[[feature]]
+id = "form.auto-name-fields"
+title = "Name new fields from nearby labels"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["form.detect"]
+tools = ["form_detect_fields"]
+evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools"]
+notes = "Names come from the label to the left (or above; right of check boxes), unique like Acrobat's (Name2, Text1)."
+
+[[feature]]
+id = "form.document-javascripts"
+title = "Document-level JavaScripts"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["tools.document_js"]
+tools = ["js_document_scripts", "js_set_document_script"]
+evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"]
+notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts."
+
+[[feature]]
+id = "form.document-actions"
+title = "Document actions (will close, will save, did save, will print, did print)"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.js-host"
+title = "JavaScript engine with the Acrobat object model subset"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "partial"
+commands = ["tools.js_console"]
+tools = ["js_run"]
+evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"]
+notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
+
+[[feature]]
+id = "form.js-console"
+title = "JavaScript console"
+area = "F"
+tier = "P1"
+milestone = "M6"
+status = "shipped"
+commands = ["tools.js_console"]
+tools = ["js_run"]
+evidence = ["crates/ui-egui/tests/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/automation.rs::javascript_through_tools"]
+notes = "\u2318J: run JavaScript against the document, with console output, results and errors."
+
+[[feature]]
+id = "form.js-debugger"
+title = "JavaScript debugger"
+area = "F"
+tier = "P1"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.edit-all-javascripts"
+title = "Edit all JavaScripts in one editor"
+area = "F"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.js-security"
+title = "JavaScript enable switch and sandboxing"
+area = "F"
+tier = "P0"
+milestone = "M6"
+status = "shipped"
+commands = ["app.preferences"]
+tools = ["js_enabled"]
+evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/document_links.rs::a_script_cannot_open_other_kinds_of_address"]
+notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91)."
+
+[[feature]]
+id = "form.folder-level-js"
+title = "Folder-level JavaScript"
+area = "F"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "form.xfa-static"
+title = "View and fill static XFA forms"
+area = "F"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["file.open"]
+tools = ["doc_open", "form_fill"]
+evidence = ["crates/engine/src/tests.rs::static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back", "crates/ui-egui/tests/ui_group/xfa.rs::a_static_xfa_form_is_filled_from_its_data_and_says_so", "crates/xfa/src/tests.rs::static_forms_read_their_values_from_the_datasets", "crates/xfa/src/tests.rs::writing_values_keeps_the_data_no_field_binds_to", "crates/xfa/src/tests.rs::utf16_packets_are_read_and_written_back_as_utf16", "crates/engine/src/tests.rs::xfa_data_no_field_binds_to_survives_an_edit_and_save"]
+notes = "the AcroForm fields are shown and filled as in any form; on open their values come from the XFA datasets (what another viewer saved) by the fields' Designer names (recorded as a document warning), and every edit merges the values back into the datasets, keeping data no field binds to, so Acrobat shows the same values; values that can't be written are warnings. Missing: no template semantics (no scripts, calculations or hidden subforms), explicit bind refs are not followed (#60)"
+
+[[feature]]
+id = "form.xfa-dynamic"
+title = "View and fill dynamic XFA forms"
+area = "F"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["file.open"]
+tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
+evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
+notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
+
+[[feature]]
+id = "form.xfa-formcalc"
+title = "FormCalc scripting"
+area = "F"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["file.open"]
+tools = ["doc_open", "form_fill", "js_run"]
+evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved"]
+notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures"
+
+[[feature]]
+id = "form.xfa-data"
+title = "XFA XML data import and export"
+area = "F"
+tier = "P2"
+milestone = "M12"
+status = "partial"
+commands = ["file.open", "file.save", "form.export_data", "form.import_data"]
+tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"]
+evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"]
+notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
+
+[[feature]]
+id = "form.xfa-flatten"
+title = "Flatten XFA forms"
+area = "F"
+tier = "P2"
+milestone = "M12"
+status = "shipped"
+commands = ["form.flatten"]
+tools = ["doc_flatten", "xfa_to_static"]
+evidence = ["crates/xfa/src/tests.rs::stripping_takes_the_xfa_out_and_leaves_an_ordinary_document", "crates/engine/src/tests.rs::flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out", "crates/engine/src/tests.rs::an_xfa_form_converts_to_a_static_form_that_keeps_its_fields", "crates/automation/tests/automation.rs::xfa_forms_flatten_and_convert_to_static_through_tools"]
+notes = "Flatten form fields on a laid-out XFA form bakes the fields into the pages and takes the XFA out (packets, /NeedsRendering, PdfCraft's private keys), so every viewer, Adobe's included, shows the baked pages rather than laying the form out again from the template. xfa_to_static (tool and engine edit) keeps the laid-out pages and the fields with their values as an ordinary AcroForm whose scripts no longer run. Both undoable until saved. Missing: a menu entry for the conversion (the tool and the MCP server have it); flattening keeps the layout as PdfCraft laid it out, not as Acrobat would (form.xfa-dynamic)"
+
+[[feature]]
+id = "form.fill-sign-text"
+title = "Fill & Sign: add text on flat forms"
+area = "F"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["sign.fill.text"]
+tools = ["fill_sign_add"]
+evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"]
+notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet"
+
+[[feature]]
+id = "form.fill-sign-marks"
+title = "Fill & Sign: check, cross, dot, line marks"
+area = "F"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["sign.fill.check", "sign.fill.cross", "sign.fill.dot", "sign.fill.line"]
+tools = ["fill_sign_add"]
+evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn"]
+notes = "PdfCraft-drawn stamps"
+
+[[feature]]
+id = "form.fill-sign-date"
+title = "Fill & Sign: today's date"
+area = "F"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["sign.fill.date"]
+tools = ["fill_sign_add", "fill_sign_date_format"]
+evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/date_format.rs::fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered", "crates/engine/src/dates.rs::presets_write_the_day_in_their_own_order", "crates/engine/src/dates.rs::names_follow_the_date_language", "crates/ui-egui/tests/date_format.rs::month_names_follow_the_date_language_or_else_the_interface_language", "crates/ui-egui/tests/date_format.rs::a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first"]
+notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 12 presets or any pattern in Acrobat date letters with \\ escapes, checked and previewed as typed, remembered; only whole date codes accepted; month and weekday names in the interface languages, Czech months in the genitive after a day, following the interface language unless one is picked; fill_sign_add takes a one-off format and language). Beyond Acrobat: its Fill & Sign has no format setting (only Acrobat Sign accounts do). Missing: Fill & Sign text is Helvetica/WinAnsi, so a date with letters outside Western European (Czech ř, Japanese, Chinese names) is refused with a warning instead of being saved as ?; the OS locale's format isn't offered as a preset; names in languages without an interface translation"
+
+[[feature]]
+id = "form.fill-sign-signature"
+title = "Fill & Sign: typed, drawn, image or scanned signature and initials"
+area = "F"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"]
+tools = ["fill_sign_add", "comment_edit", "comment_image_preview"]
+evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones"]
+notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)"
+
+[[feature]]
+id = "form.fill-sign-flatten"
+title = "Fill & Sign: flatten on save"
+area = "F"
+tier = "P0"
+milestone = "M5"
+status = "shipped"
+commands = ["comment.flatten"]
+tools = ["doc_save", "doc_flatten"]
+evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on"]
+notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable. Other comments and form fields stay. Flatten comments still flattens every comment."
+
+[[feature]]
+id = "form.autofill-profile"
+title = "Autofill collection synced to an Adobe account"
+area = "F"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; a local profile may come later"
+
+[[feature]]
+id = "form.distribute-track"
+title = "Distribute and track forms, collect responses"
+area = "F"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; only submit-to-URL/email actions are supported"
+
+[[feature]]
+id = "form.web-forms"
+title = "Acrobat Sign web forms"
+area = "F"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud"
+
+# ==================================================================================================
+# G. Protect, redact, sanitize
+# ==================================================================================================
+
+[[feature]]
+id = "protect.security-properties"
+title = "Security properties (method, which password opened it, each permission)"
+area = "G"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = ["protect.properties"]
+tools = ["doc_info"]
+evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details"]
+notes = ""
+
+[[feature]]
+id = "protect.password-to-open"
+title = "Protect with a password to open"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.password"]
+tools = ["doc_protect"]
+evidence = ["crates/ui-egui/tests/ui_group/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted", "crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"]
+notes = "Protect Using Password (Viewing); applied by the next save"
+
+[[feature]]
+id = "protect.permissions-password"
+title = "Restrict editing and printing with a permissions password"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.password"]
+tools = ["doc_protect"]
+evidence = ["crates/ui-egui/tests/ui_group/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session", "crates/automation/tests/automation.rs::protecting_through_tools"]
+notes = "Protect Using Password (Editing); the author keeps full rights after saving"
+
+[[feature]]
+id = "protect.permissions-matrix"
+title = "Choose printing, changes, copying and accessibility permissions"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.password"]
+tools = ["doc_protect"]
+evidence = ["crates/ui-egui/tests/ui_group/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session"]
+notes = "Advanced Options: printing (none/low/high), changes (5 levels), copying, screen readers"
+
+[[feature]]
+id = "protect.encryption-level"
+title = "Choose encryption compatibility (RC4 40/128, AES-128, AES-256)"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.password"]
+tools = ["doc_protect"]
+evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"]
+notes = "AES-256 (default), AES-128, RC4-128, RC4-40"
+
+[[feature]]
+id = "protect.encrypt-except-metadata"
+title = "Encrypt everything except metadata"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.password"]
+tools = ["doc_protect"]
+evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"]
+notes = "Advanced Options; disabled for RC4 levels as in Acrobat"
+
+[[feature]]
+id = "protect.encrypt-attachments-only"
+title = "Encrypt only file attachments"
+area = "G"
+tier = "P2"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.password-strength"
+title = "Password strength meter"
+area = "G"
+tier = "P2"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.password"]
+tools = []
+evidence = ["crates/ui-egui/tests/ui_group/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted"]
+notes = "Weak / Medium / Strong under the password field"
+
+[[feature]]
+id = "protect.remove-security"
+title = "Remove security"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.remove"]
+tools = ["doc_unprotect"]
+evidence = ["crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"]
+notes = "needs the permissions password (or none set)"
+
+[[feature]]
+id = "protect.certificate-encryption"
+title = "Encrypt with certificates for recipients"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.recipient-permissions"
+title = "Per-recipient permissions for certificate security"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.recipient-ldap"
+title = "Find recipients in a directory (LDAP)"
+area = "G"
+tier = "P3"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.security-policies"
+title = "Local security policies (saved presets)"
+area = "G"
+tier = "P2"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.rights-management"
+title = "Server-based rights management (AEM/LiveCycle)"
+area = "G"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "server product; local policy presets only"
+
+[[feature]]
+id = "protect.mip-labels"
+title = "Open Microsoft Purview-protected PDFs and apply labels"
+area = "G"
+tier = "P3"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "optional Windows plug-in"
+
+[[feature]]
+id = "protect.redact-text"
+title = "Mark text for redaction"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.mark"]
+tools = ["redact_mark"]
+evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put"]
+notes = "drag across text (or select first) with the Redact tool"
+
+[[feature]]
+id = "protect.redact-area"
+title = "Mark areas and images for redaction"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.mark"]
+tools = ["redact_mark"]
+evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"]
+notes = "drag a box anywhere else"
+
+[[feature]]
+id = "protect.redact-pages"
+title = "Mark whole pages for redaction"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.pages"]
+tools = ["redact_mark"]
+evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
+notes = "Mark Page Range: current page or a range"
+
+[[feature]]
+id = "protect.search-and-redact"
+title = "Find text and redact"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.search"]
+tools = ["redact_mark"]
+evidence = ["crates/automation/tests/automation.rs::redacting_through_tools"]
+notes = "single word or phrase, every match on every page"
+
+[[feature]]
+id = "protect.redact-word-lists"
+title = "Redact lists of words or phrases"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.search"]
+tools = ["redact_mark"]
+evidence = ["crates/redact/src/patterns.rs::word_lists_are_trimmed_unique_and_bounded", "crates/automation/tests/automation.rs::redacting_word_lists_through_tools", "crates/automation/tests/automation.rs::redaction_search_respects_case_and_word_boundaries", "crates/ui-egui/tests/ui_group/redact.rs::redacting_an_imported_word_list", "crates/ui-egui/tests/ui_group/redact.rs::redaction_search_can_match_case_and_whole_words"]
+notes = "Find text and redact ▸ Multiple words or phrases: one per line, typed or imported from a .txt file (at most 1 MB, 1,000 entries of 256 characters); every match of each is marked in one undo step; redact_mark words reports marks per word. Single phrase and word-list searches offer case-sensitive and whole-words-only options in the UI and headless tool. Not yet: exporting the list"
+
+[[feature]]
+id = "protect.redact-patterns"
+title = "Redact patterns (phone, email, card, SSN, dates)"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.search"]
+tools = ["redact_mark"]
+evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"]
+notes = "phone (North American, E.164 with + country code, national with trunk 0), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded"
+
+[[feature]]
+id = "protect.redact-pattern-locales"
+title = "Pattern sets per locale"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.redact-partial-words"
+title = "Redact part of a match (e.g. all but the last four digits)"
+area = "G"
+tier = "P2"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.redact-folder"
+title = "Search and redact across a folder"
+area = "G"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.redaction-properties"
+title = "Redaction fill colour and overlay text"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.properties"]
+tools = ["redact_mark"]
+evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_survive_a_restart", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_for_this_run_only_and_cancel", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"]
+notes = "Acrobat's three groups: redacted area fill colour or none; overlay text with font, size or auto-size, colour, alignment and repeat, or redaction codes (protect.redaction-codes); Redaction mark appearance: outline colour (/C), fill colour shown while the pointer is over a mark (a preference, not written), opacity (/CA). Make properties default: ticked, OK keeps the values for the next run (settings \"redact_prefs\"); unticked, this run only and the saved defaults stay; Cancel puts back what the dialog opened with."
+
+[[feature]]
+id = "protect.redaction-codes"
+title = "Redaction code sets (e.g. FOIA exemptions)"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "partial"
+commands = ["redact.properties"]
+tools = ["redact_mark"]
+evidence = ["crates/redact/src/tests.rs::redaction_codes_join_in_set_order_and_reject_strangers", "crates/automation/tests/automation.rs::redacting_with_codes_through_tools", "crates/ui-egui/tests/ui_group/redact.rs::redaction_codes_become_the_overlay_text"]
+notes = "built-in U.S. FOIA (5 U.S.C. 552(b)) and U.S. Privacy Act (552a) sets; picked codes become the overlay text, in the set's order. Missing: Edit Codes, custom code sets, import/export of code sets"
+
+[[feature]]
+id = "protect.apply-redactions"
+title = "Apply redactions (remove underlying content)"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.apply"]
+tools = ["redact_apply"]
+evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"]
+notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns"
+
+[[feature]]
+id = "protect.redact-glyph-removal"
+title = "Remove redacted text glyphs from content streams"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.apply"]
+tools = ["redact_apply"]
+evidence = ["crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put", "crates/redact/src/tests.rs::kerning_spacing_scaling_and_line_operators_are_honoured", "crates/redact/src/tests.rs::rotated_text_and_composite_fonts", "crates/redact/src/tests.rs::shared_form_xobjects_are_copied_not_changed"]
+notes = "TJ displacement keeps the rest in place; simple, composite (CMaps), Type 3 fonts"
+
+[[feature]]
+id = "protect.redact-image-pixels"
+title = "Remove redacted image pixels"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.apply"]
+tools = ["redact_apply"]
+evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"]
+notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole"
+
+[[feature]]
+id = "protect.redact-vectors"
+title = "Remove or cut redacted vector paths"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.apply"]
+tools = ["redact_apply"]
+evidence = ["crates/redact/src/tests.rs::vectors_are_removed_or_clipped_and_inline_images_go"]
+notes = "covered paths removed, others clipped; shadings clipped"
+
+[[feature]]
+id = "protect.redact-annots-fields"
+title = "Remove annotations and fields under redactions"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.apply"]
+tools = ["redact_apply"]
+evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
+notes = "with pop-ups; fields lose all widgets"
+
+[[feature]]
+id = "protect.redact-struct-tree"
+title = "Remove redacted content from tags and alternate text"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.apply"]
+tools = ["redact_apply"]
+evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed"]
+notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; untouched tags stay"
+
+[[feature]]
+id = "protect.redaction-verification"
+title = "Verify no residue remains after redaction"
+area = "G"
+tier = "P0"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.apply"]
+tools = ["redact_apply"]
+evidence = ["crates/redact/src/tests.rs::unreadable_content_fails_closed", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo"]
+notes = "re-reads redacted pages; residue or unreadable content fails the whole operation"
+
+[[feature]]
+id = "protect.redact-filename-suffix"
+title = "Add a suffix to redacted file names"
+area = "G"
+tier = "P3"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.redact-ai-suggestions"
+title = "Suggest sensitive content to redact via an AI provider"
+area = "G"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "local or user-provided model; off by default"
+
+[[feature]]
+id = "protect.remove-hidden-information"
+title = "Remove hidden information (selectable categories)"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "shipped"
+commands = ["protect.remove_hidden"]
+tools = ["doc_hidden_info", "doc_remove_hidden"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"]
+notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending"
+
+[[feature]]
+id = "protect.sanitize"
+title = "Sanitize document (remove all hidden information)"
+area = "G"
+tier = "P1"
+milestone = "M8"
+status = "shipped"
+commands = ["redact.sanitize"]
+tools = ["doc_remove_hidden"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"]
+notes = "every category, then a full rewrite on save"
+
+[[feature]]
+id = "protect.audit-faux-redactions"
+title = "Audit: detect faux redactions (opaque covers over extractable text, unapplied marks)"
+area = "G"
+tier = "P2"
+milestone = "M8"
+status = "partial"
+commands = []
+tools = ["doc_audit_redactions"]
+evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"]
+notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)."
+
+[[feature]]
+id = "protect.remove-hidden-on-close"
+title = "Remove hidden information when closing or sending"
+area = "G"
+tier = "P3"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.attachment-trust"
+title = "Block opening risky attachment types"
+area = "G"
+tier = "P1"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.url-access-control"
+title = "Control internet access from PDFs"
+area = "G"
+tier = "P1"
+milestone = "M6"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.protected-view"
+title = "Open untrusted files in a restricted view"
+area = "G"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "protect.security-envelope"
+title = "Security envelopes"
+area = "G"
+tier = "P3"
+milestone = "M8"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "legacy"
+
+# ==================================================================================================
+# H. Digital signatures
+# ==================================================================================================
+
+[[feature]]
+id = "sign.pkcs12"
+title = "Sign with a PKCS#12 (.p12/.pfx) digital ID"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/crypto.rs::opens_every_openssl_flavour_of_pkcs12", "crates/sign/tests/crypto.rs::opens_pkcs12_as_windows_writes_it", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "PBES2 (AES, 3DES) and legacy SHA-1 3DES/RC2 files; RSA, P-256, P-384; both the DER OpenSSL writes and the BER Windows writes (indefinite lengths, segmented AuthenticatedSafe)"
+
+[[feature]]
+id = "sign.macos-keychain"
+title = "Sign with macOS Keychain identities"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_keychain_ids", "sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::signing_with_keychain_identities", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "Security framework identities (RSA PKCS #1 v1.5, ECDSA P-256/P-384) listed with the digital IDs in Sign with a Digital ID; the key never leaves the Keychain (ExternalKey); keychain: for agents. The test uses a throwaway keychain file (run with --ignored)"
+
+[[feature]]
+id = "sign.windows-cert-store"
+title = "Sign with the Windows certificate store"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital", "sign.certify"]
+tools = ["sign_windows_ids", "sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen"]
+notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11."
+
+[[feature]]
+id = "sign.encrypted-documents"
+title = "Sign and timestamp password-protected (encrypted) documents"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital", "sign.certify"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::encrypted_documents_are_signed_with_an_unencrypted_contents", "crates/sign/tests/pdf.rs::encrypted_documents_are_signed_only_as_their_permissions_allow", "crates/sign/tests/pdf.rs::encrypted_documents_take_a_document_timestamp", "crates/cos/tests/encryption.rs::signature_contents_stay_unencrypted", "crates/automation/tests/automation.rs::signing_an_encrypted_form_through_tools"]
+notes = "RC4-40/128, AES-128 and AES-256 Standard security; the incremental update keeps /Encrypt and /ID, encrypts the new strings and streams, and leaves the signature /Contents unencrypted (§7.6.2), which reading also honours. Opened with the user password, permissions (Table 22) apply: existing signature fields need fill-forms, new fields need annotate + modify, certifying needs modify, a document timestamp needs fill-forms; the owner may do anything. Not yet: certificate (public-key) security, which PdfCraft can't open; checked only against PdfCraft's own validator, not yet against an external one (pyHanko) or Acrobat"
+
+[[feature]]
+id = "sign.pkcs11"
+title = "Sign with PKCS#11 tokens and smart cards"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.self-signed-id"
+title = "Create a self-signed digital ID"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_id_create"]
+evidence = ["crates/sign/tests/crypto.rs::new_digital_ids_are_self_signed_and_survive_a_p12_round_trip", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "RSA 2048/3072/4096 or P-256, saved as a password-protected .p12"
+
+[[feature]]
+id = "sign.export-certificate"
+title = "Export a public certificate"
+area = "H"
+tier = "P2"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.panel"]
+tools = []
+evidence = ["crates/sign/tests/crypto.rs::certificates_load_from_pem_and_der_and_export_as_pem"]
+notes = "Signatures panel ▸ Export certificate (.cer, PEM)"
+
+[[feature]]
+id = "sign.visible-signature"
+title = "Visible signature by drawing a rectangle"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_document"]
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "drag a rectangle; Acrobat's name-and-details appearance"
+
+[[feature]]
+id = "sign.sign-existing-field"
+title = "Sign an existing signature field"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it"]
+notes = ""
+
+[[feature]]
+id = "sign.invisible-signature"
+title = "Invisible signature"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.certify_invisible"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::invisible_signatures_and_refusals", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"]
+notes = ""
+
+[[feature]]
+id = "sign.signature-appearance"
+title = "Configure signature appearance (text, graphic, name)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = ["sign.digital"]
+tools = ["sign_document"]
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools", "crates/sign/tests/pdf.rs::appearance_labels_helvetica_cannot_draw_are_refused", "crates/ui-egui/tests/signing.rs::the_signature_labels_follow_the_interface_language", "crates/automation/tests/automation.rs::signing_with_translated_labels_through_tools"]
+notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. The labels are stamped in the interface language when Helvetica (WinAnsi) can draw all of them (German, Spanish, French, Italian, Portuguese, Polish, Turkish, Slovene, Hungarian), otherwise all in English with a note under the preview, which shows the same labels and the signing time; sign_document takes labels and the shown items (appearance), and a drawn label the font can't draw is refused, never written as ?. Missing: labels in non-Latin scripts (needs an embedded font), the DN line in the preview, named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog"
+
+[[feature]]
+id = "sign.reason-location"
+title = "Reason, location and contact info"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = ""
+
+[[feature]]
+id = "sign.lock-after-signing"
+title = "Lock document or fields after signing"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.pkcs7-detached"
+title = "PKCS#7 detached signatures"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.validate"]
+tools = ["sign_list"]
+evidence = ["crates/sign/tests/pdf.rs::validates_a_signature_made_by_openssl", "crates/sign/tests/pdf.rs::validates_signatures_written_with_ber_indefinite_lengths", "crates/sign/tests/ber.rs::deeply_nested_segmented_content_is_an_error_not_a_stack_overflow", "crates/sign/tests/ber.rs::indefinite_length_nesting_bombs_are_refused"]
+notes = "validated, including BER-encoded CMS (indefinite lengths and segmented strings, as DocuSign, Documenso, Adobe PPKMS and Windows write it; the BER is reported in the details); new signatures use CAdES. Not read: BER high tag numbers. Still missing: multiple SignerInfos, counter-signatures, CMS with the signer's cert referenced but absent"
+
+[[feature]]
+id = "sign.cades-pades-bb"
+title = "CAdES / PAdES B-B signatures"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust"]
+notes = "ETSI.CAdES.detached with signing-certificate-v2; pdfsig reports them valid"
+
+[[feature]]
+id = "sign.hash-algorithms"
+title = "SHA-256/384/512 hashes; no SHA-1 for new signatures"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.digital"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/crypto.rs::rsa_signatures_are_read_with_every_digestinfo_variant", "crates/sign/tests/crypto.rs::ecdsa_signatures_are_read_as_der_non_minimal_der_or_raw", "crates/sign/tests/crypto.rs::verifies_ecdsa_on_p521_and_the_brainpool_curves", "crates/sign/tests/crypto.rs::keys_of_unknown_algorithms_are_unsupported_not_wrong", "crates/sign/tests/crypto.rs::tolerated_encodings_are_noted_and_standard_ones_are_not", "crates/sign/tests/crypto.rs::weak_digests_say_so_in_words_that_are_pinned", "crates/sign/tests/crypto.rs::an_rsa_signature_value_not_below_the_modulus_is_refused", "crates/sign/tests/crypto.rs::hostile_rsa_signature_values_are_wrong_not_crashes", "crates/sign/tests/algorithms.rs::rsa_with_sha3_sha512_256_and_ripemd160", "crates/sign/tests/algorithms.rs::rsa_pss_with_its_declared_parameters", "crates/sign/tests/algorithms.rs::ed25519_signs_the_signed_attributes_themselves", "crates/sign/tests/algorithms.rs::ecdsa_on_brainpool_p512r1", "crates/sign/tests/algorithms.rs::brainpool_p512r1_keys_must_have_coordinates_reduced_modulo_p", "crates/sign/tests/algorithms.rs::only_weak_digests_leave_a_note_when_a_signature_validates", "crates/sign/tests/pdf.rs::validates_the_legacy_adbe_x509_rsa_sha1_format"]
+notes = "SHA-256, SHA-384 for P-384; SHA-1 refused for new signatures, validated in old ones. Validation also reads SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512, RIPEMD-160, RSA PKCS#1 with or without the DigestInfo NULL (a bare digest without any DigestInfo is refused), RSA-PSS with its declared MGF1 hash and salt length, ECDSA as DER/non-minimal DER/raw r||s on P-256, P-384, P-521 and brainpoolP256r1/P384r1/P512r1 (the last hand-written on crypto-bigint, checked against OpenSSL), Ed25519 (RFC 8419), and the legacy adbe.x509.rsa_sha1 format. An RSA value not below the modulus is refused (RFC 8017 5.2.2). SHA-1 and RIPEMD-160 validate but the signature details say the algorithm is weak (wording pinned by tests); tolerated encodings are listed in the details. An algorithm PdfCraft cannot check gives Unknown, not Invalid. Still missing: Ed448/SHAKE, DSA, brainpool curves other than P256/P384/P512, signing with the new digests"
+
+[[feature]]
+id = "sign.certify"
+title = "Certify a document (DocMDP)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.certify", "sign.certify_invisible"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"]
+notes = ""
+
+[[feature]]
+id = "sign.certify-permissions"
+title = "Allowed changes after certifying"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.certify"]
+tools = ["sign_document"]
+evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions"]
+notes = "DocMDP P=1/2/3 enforced when validating"
+
+[[feature]]
+id = "sign.field-mdp"
+title = "Field locks (FieldMDP)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.document-timestamp"
+title = "Add a document timestamp (RFC 3161)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/sign/tests/pdf.rs::a_document_timestamp_covers_the_file_and_validates"]
+notes = "timestamp_document appends a standalone /ETSI.RFC3161 signature dictionary over the whole file; validation discovers and verifies these (imprint, token signature, TSA cert validity) and reports the trusted time. Missing: automation tool and timestamp-server configuration (transport is caller-injected)."
+
+[[feature]]
+id = "sign.timestamp-servers"
+title = "Configure timestamp servers"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.pades-bt"
+title = "PAdES B-T (signature timestamp)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token"]
+notes = "Sign with an embedded RFC 3161 token (sign_with_timestamp): imprint and TSA signature verified at signing and validation time, trusted time reported. Missing: timestamp-server configuration and a fetcher (transport is caller-injected), the standalone document timestamp, automation tool."
+
+[[feature]]
+id = "sign.ltv"
+title = "Long-term validation (DSS/VRI)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/sign/tests/pdf.rs::embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"]
+notes = "dss::embed merges caller-supplied evidence into the catalog /DSS with /VRI entries per signature, deduplicating blobs, as a permitted incremental change; revocation.rs verifies the embedded CRLs and OCSP responses (signature, responder identity, CertID, window) and a verified revocation invalidates the signature. Missing: fetching evidence, automation tool."
+
+[[feature]]
+id = "sign.pades-blta"
+title = "PAdES B-LT and B-LTA"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/sign/tests/pdf.rs::sign_then_ltv_then_timestamp_makes_a_b_lta_file"]
+notes = "The B-LT/B-LTA chain works with caller-supplied evidence: sign, embed DSS (B-LT), then append a document timestamp over the result (B-LTA); earlier signatures stay valid with the store classified as a permitted change. Missing: OCSP/CRL verification of the evidence, re-timestamping automation."
+
+[[feature]]
+id = "sign.ocsp-crl"
+title = "Revocation checking (OCSP and CRL)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/sign/tests/crypto.rs::crls_verify_and_report_revocation", "crates/sign/tests/crypto.rs::ocsp_responses_verify_and_match_the_certificate", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"]
+notes = "RFC 5280 CRLs and RFC 6960 OCSP responses are parsed and verified (issuer/responder signature, delegated responder's OCSP-signing EKU, CertID hash match, validity window) and checked against the DSS during validation. Missing: network fetching of fresh evidence and the OS trust store."
+
+[[feature]]
+id = "sign.validate-all"
+title = "Validate all signatures"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.validate"]
+tools = ["sign_list"]
+evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/sign/tests/chain.rs::a_trusted_signer_whose_certificate_may_not_sign_is_not_valid"]
+notes = "The signer's key usage, extended key usage and critical extensions are checked; name constraints are not"
+
+[[feature]]
+id = "sign.signatures-panel"
+title = "Signatures panel with per-revision tree"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "partial"
+commands = ["sign.panel"]
+tools = ["sign_list"]
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
+notes = "per-signature tree with details and actions; no per-revision page outline yet"
+
+[[feature]]
+id = "sign.status-icons"
+title = "Signature status (valid, unknown, invalid)"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.panel"]
+tools = ["sign_list"]
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
+notes = ""
+
+[[feature]]
+id = "sign.validation-banner"
+title = "Signed/certified document message bar"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.panel"]
+tools = []
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"]
+notes = "signed/valid, problems, invalid and certified bars"
+
+[[feature]]
+id = "sign.signature-properties"
+title = "Signature properties (validity summary, signer, time)"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "partial"
+commands = ["sign.panel"]
+tools = ["sign_list"]
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
+notes = "validity summary, signer, issuer, time, algorithm in the panel; no separate properties dialog"
+
+[[feature]]
+id = "sign.certificate-viewer"
+title = "Certificate viewer"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.panel"]
+tools = ["sign_list"]
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
+notes = "Signatures panel ▸ Show certificate: the chain (root on top) and Summary, Details (serial, key, constraints, usage, SHA-1/SHA-256 digests) and Trust tabs; add to trusted certificates; export"
+
+[[feature]]
+id = "sign.trust-store"
+title = "Trusted certificates store"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.panel"]
+tools = ["sign_trust"]
+evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "trusted certificates persist with the app's settings"
+
+[[feature]]
+id = "sign.trust-import"
+title = "Import trusted certificates and set trust"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = []
+tools = ["sign_trust"]
+evidence = ["crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "import .cer/.pem/.der/.p12 through the tool; per-certificate trust settings not yet"
+
+[[feature]]
+id = "sign.eutl"
+title = "EU Trusted Lists"
+area = "H"
+tier = "P2"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.os-trust"
+title = "Trust the operating system's root certificates"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.verification-time"
+title = "Verification time (signing time, timestamp, now)"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token", "crates/sign/tests/pdf.rs::an_untrusted_timestamp_does_not_set_the_validation_time"]
+notes = "Validation prefers the verified RFC 3161 timestamp time over /M when checking certificate validity and revocation, but only when the TSA chains to the trust store; an untrusted token is reported as an unverified timestamp and its time is not used. Missing: an explicit verification-time option (verify at a chosen date / now) in the validation API."
+
+[[feature]]
+id = "sign.changes-after-signing"
+title = "List changes made after signing (MDP-aware diff)"
+area = "H"
+tier = "P0"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.panel"]
+tools = ["sign_list"]
+evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry"]
+notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787)"
+
+[[feature]]
+id = "sign.view-signed-version"
+title = "View the signed version"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = ["sign.panel"]
+tools = []
+evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
+notes = ""
+
+[[feature]]
+id = "sign.compare-signed-version"
+title = "Compare signed version with the current one"
+area = "H"
+tier = "P1"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.verify-on-open"
+title = "Verify signatures when a document opens"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "shipped"
+commands = []
+tools = ["sign_list"]
+evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"]
+notes = "validated when opened and after every edit"
+
+[[feature]]
+id = "sign.clear-signature"
+title = "Clear an unsaved signature; clear all signature fields"
+area = "H"
+tier = "P2"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.seed-values"
+title = "Signature seed values"
+area = "H"
+tier = "P2"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.preview-mode"
+title = "Preview document mode before signing"
+area = "H"
+tier = "P2"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.qualified-labels"
+title = "Show qualified signature/seal labels"
+area = "H"
+tier = "P2"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.web-signing"
+title = "Sign in the browser with a PKCS#12 file"
+area = "H"
+tier = "P1"
+milestone = "M9"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "sign.aatl"
+title = "Adobe Approved Trust List"
+area = "H"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe-hosted list; use OS trust stores and EU Trusted Lists"
+
+[[feature]]
+id = "sign.request-esignatures"
+title = "Request e-signatures (Acrobat Sign)"
+area = "H"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; pluggable provider hook only"
+
+[[feature]]
+id = "sign.cloud-signatures"
+title = "Cloud signature (CSC) digital IDs via Adobe"
+area = "H"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; a pluggable remote-signing KeyProvider may follow"
+
+# ==================================================================================================
+# I. Scan and OCR
+# ==================================================================================================
+
+[[feature]]
+id = "ocr.recognize-text"
+title = "Recognize text (OCR) in this file"
+area = "I"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["ocr.recognize"]
+tools = ["ocr_recognize", "ocr_status"]
+evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"]
+notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."
+
+[[feature]]
+id = "ocr.recognize-range"
+title = "Recognize text in the current page or a range"
+area = "I"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["ocr.recognize"]
+tools = ["ocr_recognize"]
+evidence = ["crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"]
+notes = "All pages, current page or a range."
+
+[[feature]]
+id = "ocr.recognize-multiple"
+title = "Recognize text in multiple files"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["ocr.recognize_batch"]
+tools = ["ocr_recognize_files"]
+evidence = ["crates/automation/tests/automation.rs::ocr_recognize_files_writes_searchable_copies", "crates/ui-egui/tests/ocr.rs::recognize_text_in_multiple_files_writes_searchable_copies"]
+notes = "Pick PDFs, then an output folder; copies are saved incrementally under the same names, in the background with progress."
+
+[[feature]]
+id = "ocr.ocr-languages"
+title = "OCR document language selection"
+area = "I"
+tier = "P0"
+milestone = "M10"
+status = "partial"
+commands = ["ocr.recognize"]
+tools = ["ocr_recognize"]
+evidence = ["crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"]
+notes = "Language choice exists, but the models read only the Latin alphabet without accents (English)."
+
+[[feature]]
+id = "ocr.searchable-image"
+title = "Searchable image output (invisible text layer)"
+area = "I"
+tier = "P0"
+milestone = "M10"
+status = "partial"
+commands = ["ocr.recognize"]
+tools = ["ocr_recognize"]
+evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable"]
+notes = "Invisible text (Tr 3) fitted to each word box; the image is not deskewed or downsampled (exact mode only)."
+
+[[feature]]
+id = "ocr.searchable-image-exact"
+title = "Searchable image (exact): leave the image untouched"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["ocr.recognize"]
+tools = ["ocr_recognize"]
+evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable"]
+notes = "The image is left untouched."
+
+[[feature]]
+id = "ocr.editable-text-output"
+title = "Editable text and images output"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "partial"
+commands = ["edit.edit_text"]
+tools = ["text_edit"]
+evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open"]
+notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Still missing: Acrobat's Recognize Text output that converts a whole page to editable text at once, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)."
+
+[[feature]]
+id = "ocr.ocr-downsample"
+title = "Downsample images during OCR"
+area = "I"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.skip-renderable-text"
+title = "Skip pages that already have text"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["ocr.recognize"]
+tools = ["ocr_recognize"]
+evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/engine/src/tests.rs::recognize_text_reads_a_picture_on_a_page_that_has_text", "crates/automation/tests/automation.rs::ocr_recognize_reads_pictures_on_pages_with_text", "crates/ui-egui/tests/ocr.rs::recognize_text_can_read_pictures_on_pages_with_text"]
+notes = "Pages that already have text are skipped and reported by default. The dialog's \"Also recognize pages that already have text\" (skip_text_pages false, also for multiple files) reads pictures on such pages (#1028); a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out and counted (already_on_page). Missing: recognising only the image areas (the whole page is still read, so it takes as long as a scan), and a word in a picture that sits under real text is treated as that text."
+
+[[feature]]
+id = "ocr.rotation-detection"
+title = "Detect and fix page rotation"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.deskew"
+title = "Deskew"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.despeckle"
+title = "Despeckle"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "partial"
+commands = ["ocr.enhance_camera"]
+tools = ["page_enhance_camera"]
+evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
+notes = "Camera images only: from clean-up level 60, isolated dark pixels are removed. Missing: for scanned pages and as an OCR option."
+
+[[feature]]
+id = "ocr.background-removal"
+title = "Background removal"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "partial"
+commands = ["ocr.enhance_camera"]
+tools = ["page_enhance_camera"]
+evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
+notes = "Camera images only: Enhance camera image's clean-up level (and page_enhance_camera level) evens out the paper's illumination per channel, so the background turns white and colour casts go; colour ink stays. Missing: the same for scanned pages and as an OCR option."
+
+[[feature]]
+id = "ocr.descreen"
+title = "Descreen"
+area = "I"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.remove-halo"
+title = "Remove halos"
+area = "I"
+tier = "P3"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.edge-shadow-removal"
+title = "Edge shadow removal"
+area = "I"
+tier = "P2"
+milestone = "M10"
+status = "partial"
+commands = ["ocr.enhance_camera"]
+tools = ["page_enhance_camera"]
+evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
+notes = "Camera images only: the clean-up level divides out a smooth illumination estimate, which removes shadows and darker edges on the straightened photo. Missing: for scanned pages (book gutters) and as an OCR option."
+
+[[feature]]
+id = "ocr.camera-perspective"
+title = "Enhance camera images (edges, perspective, contrast)"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "partial"
+commands = ["ocr.enhance_camera"]
+tools = ["page_enhance_camera"]
+evidence = ["crates/engine/src/tests.rs::enhance_camera_page_straightens_the_sheet", "crates/automation/tests/automation.rs::enhance_camera_image_through_tools", "crates/ui-egui/tests/camera.rs::dragging_a_corner_then_enhance_page_straightens_the_page", "crates/ui-egui/tests/camera.rs::after_enhance_page_crop_and_organize_take_over", "crates/engine/src/tests.rs::detect_camera_corners_finds_the_sheet_in_user_space", "crates/automation/tests/automation.rs::enhance_camera_image_finds_the_edges_by_itself"]
+notes = "Four corners (any order, a convex quadrilateral) are warped to an upright page at the picture's own resolution, in one undoable step; comments, links and bookmarks stay. Scan & OCR ▸ Enhance camera image shows four corner handles on the current page (Enhance page / Cancel / Esc), then offers rotate either way, Crop pages and Organize pages for the enhanced page; page_enhance_camera does the same headlessly. The corners start on the document's edges when they are found (straight lines with an edge along most of each side, refined by a line fit; tested on synthetic photos with text, wood grain, shadows, strong perspective and a 30° turn), else inset 5%. After Enhance page a clean-up slider (0–100, default 50) re-enhances from the straightened photo: even lighting, white paper, contrast, despeckle from 60 (camera_clean). Missing: turning the result upright (text orientation), rounded-corner cards keep their background in the corners, document types."
+
+[[feature]]
+id = "ocr.camera-document-type"
+title = "Camera image document types (whiteboard, card, form)"
+area = "I"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.correct-recognized-text"
+title = "Correct recognized text (low-confidence suspects)"
+area = "I"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.adaptive-compression"
+title = "Adaptive (MRC) compression of scans"
+area = "I"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.scan-pdfa"
+title = "Make scans PDF/A compliant"
+area = "I"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.ocr-engines"
+title = "Pluggable OCR engines (ocrs default; tesseract optional)"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.ocr-web"
+title = "OCR in the browser"
+area = "I"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.scanner-acquire"
+title = "Scan from TWAIN/WIA/ICA/SANE scanners"
+area = "I"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.scan-presets"
+title = "Scan presets"
+area = "I"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.scan-append"
+title = "Append scans to an existing PDF or split into files"
+area = "I"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "ocr.adobe-scan-sync"
+title = "Adobe Scan mobile capture sync"
+area = "I"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; import images from any source instead"
+
+# ==================================================================================================
+# J. Create and export
+# ==================================================================================================
+
+[[feature]]
+id = "create.from-images"
+title = "Create PDF from images (JPEG, PNG, TIFF, GIF, BMP, JPEG 2000)"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["create.images"]
+tools = ["doc_create"]
+evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::embedded_icc_profiles_tag_the_image_colour_space"]
+notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution). JPEG and PNG ICC profiles are kept as /ICCBased; TIFF profiles are not read yet"
+
+[[feature]]
+id = "create.from-multipage-tiff"
+title = "Multi-page TIFF to multi-page PDF"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["create.images", "create.file"]
+tools = ["doc_create"]
+evidence = ["crates/create/src/tests.rs::bmp_gif_and_multi_page_tiff_images", "crates/create/src/tests.rs::bigtiff_images_become_pages"]
+notes = "every page; 1-bit scans stay 1-bit; resolution from the TIFF; classic TIFF and BigTIFF"
+
+[[feature]]
+id = "create.from-text"
+title = "Create PDF from plain text"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["create.file"]
+tools = ["doc_create"]
+evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"]
+notes = "Helvetica 11 pt on US Letter; form feeds start pages"
+
+[[feature]]
+id = "create.from-markdown"
+title = "Create PDF from Markdown"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.from-clipboard"
+title = "Create PDF from the clipboard"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["create.clipboard"]
+tools = []
+evidence = ["crates/ui-egui/tests/create.rs::clipboard_images_and_text_become_new_pdfs"]
+notes = "an image (one page at its size) or text; headless tools read files instead"
+
+[[feature]]
+id = "create.from-screenshot"
+title = "Create PDF from a screen capture"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.blank"
+title = "Create a blank PDF"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["create.blank"]
+tools = ["doc_create"]
+evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"]
+notes = ""
+
+[[feature]]
+id = "create.from-html"
+title = "Create PDF from an HTML file"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.from-web-page"
+title = "Create PDF from a web page URL"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.from-office"
+title = "Create PDF from Office documents (LibreOffice sidecar)"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.create-multiple"
+title = "Convert many files to PDF in one run"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["create.multiple"]
+tools = ["doc_create_multiple"]
+evidence = ["crates/engine/src/tests.rs::mixed_files_convert_and_combine_in_order", "crates/engine/src/tests.rs::files_that_cannot_be_converted_are_refused_clearly", "crates/automation/tests/automation.rs::creating_from_multiple_files_through_tools", "crates/ui-egui/tests/create.rs::multiple_files_open_as_one_document_in_the_page_grid", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order", "crates/ui-egui/tests/editing.rs::save_pages_writes_what_the_grid_shows"]
+notes = "Create ▸ Multiple files: the picked PDFs, images (PNG, JPEG, JPEG 2000, TIFF, GIF, BMP) and .txt files open as one unsaved document in the page grid, in the order picked (a bookmark per file); there pages are removed, reordered, more files inserted at any gap (a + button, or dropped there), and Save pages writes what is shown. One PDF per file is offered by the doc_create_multiple tool only (mode separate), not in the app. Missing: Office, HTML and Markdown inputs, PDF Portfolio output, per-file image resolution choice, progress and cancel for large batches (conversion runs on the UI thread). The combined file has combine's limits: untagged, no page labels or document info carried over, and its per-file bookmarks are not updated when pages are later moved between files."
+
+[[feature]]
+id = "create.image-conversion-options"
+title = "Image compression options when converting"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "partial"
+commands = ["create.images"]
+tools = ["doc_create"]
+evidence = ["crates/create/src/tests.rs::image_resolution_override_changes_size_without_resampling", "crates/automation/tests/automation.rs::creating_images_with_dpi_through_tools", "crates/ui-egui/tests/create.rs::image_import_dialog_chooses_dpi_and_cancels"]
+notes = "Page sizing: embedded resolution (72 dpi fallback), fixed 72 dpi, or custom 1–1200 dpi; pixels are not resampled. Compression options are still absent. Windows image context menu opens the same chooser; other OS context-menu integration not yet."
+
+[[feature]]
+id = "create.pdf-printer"
+title = "Print to PDF from other applications"
+area = "J"
+tier = "P3"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.distiller"
+title = "PostScript/EPS to PDF (Distiller)"
+area = "J"
+tier = "P3"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "deferred: a PostScript interpreter is a large project"
+
+[[feature]]
+id = "create.job-options"
+title = "PDF settings presets (job options)"
+area = "J"
+tier = "P3"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-docx"
+title = "Export to Word (.docx)"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "partial"
+commands = ["export.docx"]
+tools = ["doc_export_office"]
+evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
+notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
+
+[[feature]]
+id = "create.export-rtf"
+title = "Export to RTF"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["export.rtf"]
+tools = ["doc_export_office"]
+evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
+notes = "Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out."
+
+[[feature]]
+id = "create.export-xlsx"
+title = "Export to Excel (.xlsx) with table detection"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-pptx"
+title = "Export to PowerPoint (.pptx)"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "partial"
+commands = ["export.pptx"]
+tools = ["doc_export_office"]
+evidence = [
+ "crates/export/src/pptx.rs::pptx_is_a_presentationml_package",
+ "crates/export/src/pptx.rs::pptx_slide_has_title_body_and_images_in_order",
+ "crates/export/src/pptx.rs::pptx_text_colour_and_formatting",
+ "crates/export/src/pptx.rs::pptx_empty_pages_produce_valid_slide",
+ "crates/export/src/pptx.rs::pptx_slide_dimensions_and_clamping",
+ "crates/export/src/pptx.rs::pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title",
+ "crates/export/src/pptx.rs::pptx_opens_in_libreoffice",
+ "crates/export/src/pptx.rs::pptx_package_parts_are_complete_and_consistent",
+ "crates/export/src/pptx.rs::hostile_sizes_do_not_overflow_the_slide_position",
+ "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format",
+ "crates/export/src/lib.rs::text_colour_survives_every_format",
+ "crates/engine/src/tests.rs::exporting_office_files_keeps_images",
+ "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour",
+ "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf",
+ "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf",
+]
+notes = "PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead."
+
+[[feature]]
+id = "create.export-png"
+title = "Export pages as PNG"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["export.image"]
+tools = ["doc_export_images", "page_render"]
+evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/engine/src/tests.rs::export_images_and_text_follow_the_working_file", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
+notes = "PNG at 72–600 dpi, page ranges; includes unsaved edits; background thread with progress"
+
+[[feature]]
+id = "create.export-jpeg-tiff"
+title = "Export pages as JPEG or TIFF"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["export.image"]
+tools = ["doc_export_images"]
+evidence = ["crates/engine/src/tests.rs::pages_export_as_png_jpeg_and_tiff", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
+notes = "JPEG with quality, TIFF; flattened onto white"
+
+[[feature]]
+id = "create.export-jpeg2000"
+title = "Export pages as JPEG 2000"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-all-images"
+title = "Export all embedded images"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["export.all_images"]
+tools = ["doc_export_all_images"]
+evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text"]
+notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter"
+
+[[feature]]
+id = "create.export-html"
+title = "Export to HTML"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "partial"
+commands = ["export.html"]
+tools = ["doc_export_office"]
+evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
+notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts."
+
+[[feature]]
+id = "create.export-text"
+title = "Export to plain text"
+area = "J"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["export.text"]
+tools = ["doc_export_text", "text_extract"]
+evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
+notes = "plain reading-order text; accessible text (tags) comes with M12"
+
+[[feature]]
+id = "create.export-accessible-text"
+title = "Export accessible text (from tags)"
+area = "J"
+tier = "P1"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-xml"
+title = "Export to XML"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-svg"
+title = "Export to SVG"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-ps"
+title = "Export to PostScript"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-eps"
+title = "Export to EPS"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-ocr"
+title = "Run OCR automatically when exporting scans"
+area = "J"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.export-settings"
+title = "Per-format export settings"
+area = "J"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "create.text-extraction"
+title = "Extract text in reading order (columns, RTL, CJK)"
+area = "J"
+tier = "P0"
+milestone = "M2"
+status = "shipped"
+commands = []
+tools = ["text_extract"]
+evidence = ["crates/render/src/text.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text.rs::lines_words_and_search", "crates/automation/tests/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"]
+notes = ""
+
+[[feature]]
+id = "create.translate"
+title = "Translate documents"
+area = "J"
+tier = "P3"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "through an optional AiProvider; never Adobe's cloud"
+
+[[feature]]
+id = "create.cloud-office-conversion"
+title = "Cloud conversion from the macOS Office add-in"
+area = "J"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; LibreOffice sidecar instead"
+
+# ==================================================================================================
+# K. Optimize and standards
+# ==================================================================================================
+
+[[feature]]
+id = "optimize.reduce-file-size"
+title = "Reduce file size (one click)"
+area = "K"
+tier = "P0"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.reduce"]
+tools = ["doc_reduce"]
+evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"]
+notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel"
+
+[[feature]]
+id = "optimize.compatibility-level"
+title = "Make compatible with a chosen PDF version"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.optimizer"
+title = "Advanced optimization (PDF Optimizer)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"]
+notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit"
+
+[[feature]]
+id = "optimize.downsample-images"
+title = "Downsample colour, grey and mono images"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"]
+notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); monochrome not yet"
+
+[[feature]]
+id = "optimize.recompress-images"
+title = "Recompress images (JPEG, JPEG 2000, Flate)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens"]
+notes = "JPEG (quality steps) and ZIP; no JPEG 2000"
+
+[[feature]]
+id = "optimize.mono-compression"
+title = "Monochrome compression (JBIG2, CCITT G4)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.only-if-smaller"
+title = "Optimize images only if smaller"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled"]
+notes = "always on"
+
+[[feature]]
+id = "optimize.unembed-fonts"
+title = "Unembed fonts"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.flatten-transparency"
+title = "Flatten transparency"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.discard-javascript"
+title = "Discard JavaScript actions"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
+notes = "with links and actions (Remove Hidden Information)"
+
+[[feature]]
+id = "optimize.discard-thumbnails"
+title = "Discard embedded thumbnails"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-tags"
+title = "Discard document tags"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-bookmarks"
+title = "Discard bookmarks"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-search-index"
+title = "Discard embedded search index"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.discard-print-settings"
+title = "Discard embedded print settings"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-alternate-images"
+title = "Discard alternate images"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-form-actions"
+title = "Discard form submit, import and reset actions"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.flatten-form-fields"
+title = "Flatten form fields while optimizing"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-comments"
+title = "Discard comments, forms and multimedia"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-metadata"
+title = "Discard document information and metadata"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-attachments"
+title = "Discard file attachments"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-private-data"
+title = "Discard private application data (PieceInfo)"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
+notes = ""
+
+[[feature]]
+id = "optimize.discard-external-refs"
+title = "Discard external cross-references"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.discard-hidden-layers"
+title = "Discard hidden layers and flatten visible ones"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "partial"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"]
+notes = "hidden layer content removed; visible layers not flattened"
+
+[[feature]]
+id = "optimize.merge-image-fragments"
+title = "Detect and merge image fragments"
+area = "K"
+tier = "P3"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.lines-to-curves"
+title = "Convert smooth lines to curves"
+area = "K"
+tier = "P3"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.compress-structure"
+title = "Compress document structure (object streams)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["file.save_as"]
+tools = ["doc_save"]
+evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams"]
+notes = "on by default for full saves; no Optimizer option yet"
+
+[[feature]]
+id = "optimize.flate-unencoded"
+title = "Flate-encode unencoded streams; replace LZW with Flate"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up"]
+notes = "unencoded streams; LZW not yet re-encoded"
+
+[[feature]]
+id = "optimize.remove-invalid-links"
+title = "Remove invalid links, bookmarks and unreferenced destinations"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_optimize"]
+evidence = ["crates/optimize/tests/optimize.rs::invalid_links_and_unreferenced_destinations_go"]
+notes = "Clean Up: links to missing pages or names are removed; bookmarks keep their place (and children) but lose a broken destination; named destinations nothing refers to (links, bookmarks, actions anywhere) are removed from the name tree and the old /Dests; counts reported"
+
+[[feature]]
+id = "optimize.optimize-content"
+title = "Optimize page content streams"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.audit-space"
+title = "Audit space usage"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "shipped"
+commands = ["optimize.advanced"]
+tools = ["doc_audit_space"]
+evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/viewer.rs::space_audit_numbers_line_up_under_their_headers"]
+notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)"
+
+[[feature]]
+id = "optimize.optimizer-presets"
+title = "Optimizer presets"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.batch-optimize"
+title = "Optimize multiple files"
+area = "K"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight"
+title = "Preflight analysis"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-fix"
+title = "Preflight analyze and fix"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-profiles"
+title = "Built-in preflight profiles"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-single-checks"
+title = "Single checks"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-single-fixups"
+title = "Single fixups"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-custom-profiles"
+title = "Create and edit custom profiles"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-profile-exchange"
+title = "Import, export and lock profiles"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-results"
+title = "Preflight results tree with object details"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-snap-view"
+title = "Snap view of a flagged object"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-reports"
+title = "Preflight reports (PDF, XML, text)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-droplets"
+title = "Preflight droplets and batch preflight"
+area = "K"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.preflight-audit-trail"
+title = "Embed preflight audit trail"
+area = "K"
+tier = "P3"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.pdfa-validate"
+title = "Verify PDF/A compliance (1-4)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["standards.pdfa"]
+tools = ["pdfa_verify"]
+evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
+notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison."
+
+[[feature]]
+id = "optimize.pdfa-convert"
+title = "Save as PDF/A (1b, 2b, 3b, 4)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["standards.pdfa"]
+tools = ["pdfa_convert"]
+evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
+notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1."
+
+[[feature]]
+id = "optimize.pdfa-accessible-levels"
+title = "PDF/A conformance levels a and u"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.pdfx-validate"
+title = "Verify PDF/X compliance"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.pdfx-convert"
+title = "Save as PDF/X (1a, 3, 4)"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.output-intents"
+title = "Choose output intents and conditions"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["standards.pdfa"]
+tools = ["pdfa_convert", "pdfa_verify"]
+evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
+notes = "sRGB output intent added and listed; no choice of other conditions yet."
+
+[[feature]]
+id = "optimize.pdfe"
+title = "PDF/E"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.pdfvt"
+title = "PDF/VT"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.pdfua-validate"
+title = "Verify PDF/UA-1 and PDF/UA-2"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.standards-panel"
+title = "Standards panel showing declared conformance"
+area = "K"
+tier = "P1"
+milestone = "M11"
+status = "partial"
+commands = ["standards.pdfa"]
+tools = ["pdfa_verify"]
+evidence = ["crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
+notes = "Declared PDF/A, PDF/UA and output intents shown in the PDF/A dialog and returned by pdfa_verify."
+
+[[feature]]
+id = "optimize.remove-standards-id"
+title = "Remove PDF/A or PDF/X identification"
+area = "K"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.pdfx5-reference-xobjects"
+title = "Show PDF/X-5 reference XObjects"
+area = "K"
+tier = "P3"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "optimize.einvoice-pdfa3"
+title = "Embed e-invoice XML in PDF/A-3"
+area = "K"
+tier = "P3"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+# ==================================================================================================
+# L. Print and print production
+# ==================================================================================================
+
+[[feature]]
+id = "print.print-dialog"
+title = "Print dialog"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print", "printers"]
+evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"]
+notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog"
+
+[[feature]]
+id = "print.printer-selection"
+title = "Choose printer and properties"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["printers", "printer_options", "doc_print"]
+evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job"]
+notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't)"
+
+[[feature]]
+id = "print.copies-collate"
+title = "Copies and collation"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
+notes = "lp -n, collate option; on Windows the driver's Copies and Collate settings, as the job's"
+
+[[feature]]
+id = "print.page-range"
+title = "Print all, current page or a range (labels allowed)"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/automation.rs::printing_through_tools"]
+notes = "all, current, ranges with labels and open ends"
+
+[[feature]]
+id = "print.odd-even-reverse"
+title = "Odd/even pages and reverse order"
+area = "L"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::page_selection"]
+notes = ""
+
+[[feature]]
+id = "print.scaling"
+title = "Fit, actual size, shrink oversized, custom scale"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"]
+notes = "fit, actual size, shrink oversized, custom %; ARCH A/B/C/D/E1/E, ANSI C/D/E, ISO A0/A1/A2 and A6 presets; Match page at 100% matches the current page to a standard sheet. PDF scale shows the previewed source-to-sheet transform, not calibrated physical output; driver substitution/scaling remains possible"
+
+[[feature]]
+id = "print.paper-source-by-size"
+title = "Choose paper source by PDF page size"
+area = "L"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.duplex"
+title = "Print on both sides"
+area = "L"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
+notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting"
+
+[[feature]]
+id = "print.n-up"
+title = "Multiple pages per sheet"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"]
+notes = "2/4/6/9/16, four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused."
+
+[[feature]]
+id = "print.booklet"
+title = "Booklet printing"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding"]
+notes = "both/front/back, left/right binding"
+
+[[feature]]
+id = "print.poster"
+title = "Poster / tiled printing with overlap and cut marks"
+area = "L"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::posters_tile_with_overlap", "crates/automation/tests/automation.rs::printing_through_tools"]
+notes = "tile scale, overlap, cut marks (labels pending)"
+
+[[feature]]
+id = "print.orientation"
+title = "Auto portrait/landscape"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"]
+notes = "auto portrait/landscape"
+
+[[feature]]
+id = "print.comments-and-forms"
+title = "Print document, markups, stamps or form fields only"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms"]
+notes = "Print flag honoured"
+
+[[feature]]
+id = "print.print-with-comment-summary"
+title = "Print with a comments summary"
+area = "L"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.print-preview"
+title = "Print preview"
+area = "L"
+tier = "P0"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = []
+evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf"]
+notes = "live sheet preview with sheet navigation"
+
+[[feature]]
+id = "print.grayscale"
+title = "Print in grayscale"
+area = "L"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"]
+notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too. Still missing against Acrobat: a grey conversion of the vector pages without rendering them."
+
+[[feature]]
+id = "print.print-as-image"
+title = "Print as image"
+area = "L"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"]
+notes = "The Print dialog's Advanced section (folded unless on): Print as image at 150, 300 or 600 dpi; doc_print as_image and dpi (72–600). The pages used are rendered once each the way the viewer shows them (comments left out when the print leaves them out), composed over white, made into an image-only document and imposed on the paper like the pages themselves. The print is no longer searchable text. Still missing against Acrobat: per-printer defaults for it."
+
+[[feature]]
+id = "print.page-setup"
+title = "Page setup"
+area = "L"
+tier = "P1"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.print-ready-pdf"
+title = "Produce a print-ready imposed PDF"
+area = "L"
+tier = "P1"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = ["doc_print"]
+evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"]
+notes = "Save as PDF from the dialog"
+
+[[feature]]
+id = "print.print-pages-from-thumbnails"
+title = "Print selected pages from thumbnails"
+area = "L"
+tier = "P2"
+milestone = "M10"
+status = "shipped"
+commands = ["print.dialog"]
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::print_shortcut_offers_the_pages_picked_in_the_pages_panel", "crates/ui-egui/tests/editing.rs::pages_panel_command_click_picks_pages_without_moving_the_document", "crates/ui-egui/tests/editing.rs::pages_panel_shift_click_picks_a_range_and_a_plain_click_starts_over", "crates/ui-egui/tests/print.rs::print_dialog_prints_the_selected_pages", "crates/print/src/tests.rs::page_selection"]
+notes = "Ctrl/⌘-click and ⇧-click pick thumbnails in the Pages panel (and the Organize grid); Print opens on \"Selected pages\" and prints them in page order. Missing: the thumbnails' own context-menu Print command, picking by dragging a marquee, arrow-key selection, and a printer on Windows (Save as PDF only there, see print.printer-selection)."
+
+[[feature]]
+id = "print.postscript-output"
+title = "PostScript Level 3 output"
+area = "L"
+tier = "P2"
+milestone = "M10"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.output-preview"
+title = "Output preview"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.separations"
+title = "Separations preview with area coverage"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.total-area-coverage"
+title = "Total area coverage warning"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.color-warnings"
+title = "Overprint and rich-black warnings"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.object-inspector"
+title = "Object inspector"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.simulate-paper-ink"
+title = "Simulate paper colour and black ink"
+area = "L"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.overprint-preview"
+title = "Overprint preview"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.convert-colors"
+title = "Convert colours"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.ink-manager"
+title = "Ink manager"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.ink-aliases"
+title = "Ink aliases and spot-to-process"
+area = "L"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.printer-marks"
+title = "Add printer marks (crop, bleed, registration, colour bars)"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.fix-hairlines"
+title = "Fix hairlines"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.flattener-preview"
+title = "Flattener preview"
+area = "L"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.color-management"
+title = "Colour management settings (working spaces, intents)"
+area = "L"
+tier = "P1"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.advanced-print-separations"
+title = "Print separations and in-RIP separations"
+area = "L"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.print-marks-bleeds"
+title = "Marks and bleeds when printing"
+area = "L"
+tier = "P2"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.trap-presets"
+title = "Trap presets (stored as data)"
+area = "L"
+tier = "P3"
+milestone = "M11"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "print.jdf"
+title = "JDF job definitions (read-only)"
+area = "L"
+tier = "P3"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+# ==================================================================================================
+# M. Accessibility
+# ==================================================================================================
+
+[[feature]]
+id = "a11y.app-screen-reader"
+title = "Screen-reader support for the app (AccessKit)"
+area = "M"
+tier = "P0"
+milestone = "M3"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id"]
+notes = "AccessKit labels on all custom widgets; full audit in M14.3"
+
+[[feature]]
+id = "a11y.keyboard-only"
+title = "Full keyboard operation of the app"
+area = "M"
+tier = "P0"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.checker"
+title = "Accessibility checker (full check)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/a11y/src/tests.rs::structure_findings_identify_indirect_elements_without_guessing_inline_objects", "crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::annotation_findings_only_locate_valid_rectangles", "crates/automation/tests/automation.rs::accessibility_check_locates_field_description_on_page", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/ui-egui/tests/accessibility.rs::checker_shows_a_figure_finding_on_its_page", "crates/ui-egui/tests/accessibility.rs::checker_summary_does_not_hide_manual_review", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "full check: options dialog (report, page range, 32 rules by category, 31 on by default), results panel by category with Passed / Failed / Needs manual check / Skipped and findings that go to their page. A zero-failure check reports manual or skipped rules rather than claiming no issues. Locatable figure, annotation and form-widget findings flash their bounds on the page; accessibility_check exposes those bounds in PDF user space. Other findings navigate to their page. Structure findings expose the indirect tag object number and generation through accessibility_check, when available, for precise follow-up in the Tags Panel."
+
+[[feature]]
+id = "a11y.checker-report"
+title = "Accessibility report"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.report"]
+tools = ["accessibility_report", "accessibility_review_record", "accessibility_review_html"]
+evidence = ["crates/a11y/src/tests.rs::rules_have_ids_and_the_report_lists_them", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them", "crates/automation/src/a11y.rs::document_names_are_escaped_in_review_html"]
+notes = "HTML checker report: summary counts and a table per category with descriptions and findings. The headline shows manual or skipped rules when there are no automated failures. Headless comparison of two open versions exports the same before-and-after evidence as JSON or accessible, self-contained HTML with hashes, rule changes, and remaining review items."
+
+[[feature]]
+id = "a11y.checker-fix"
+title = "Fix, skip, explain and re-check rules"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_fix", "accessibility_review_record"]
+evidence = ["crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them"]
+notes = "right-click a rule: Fix (language via Document Properties, title, tab order), Skip Rule (kept on Check Again), Explain, Check Again, Show Report, Options. The panel compares outcome counts and changed rules with the previous check of the same document and options; the headless review record compares two open revisions."
+
+[[feature]]
+id = "a11y.rule-permission-flag"
+title = "Rule: accessibility permission flag"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules"]
+notes = "rule permission-flag: the file's /P bit 10 whichever password opened it"
+
+[[feature]]
+id = "a11y.rule-image-only"
+title = "Rule: image-only PDF"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"]
+notes = "rule image-only: images and no text on the checked pages"
+
+[[feature]]
+id = "a11y.rule-tagged-pdf"
+title = "Rule: tagged PDF"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules"]
+notes = "rule tagged-pdf: StructTreeRoot and MarkInfo Marked true"
+
+[[feature]]
+id = "a11y.rule-logical-order"
+title = "Rule: logical reading order (manual)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
+notes = "rule logical-reading-order: needs manual check"
+
+[[feature]]
+id = "a11y.rule-primary-language"
+title = "Rule: primary language"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "rule primary-language: catalog /Lang; fix via Document Properties or accessibility_fix"
+
+[[feature]]
+id = "a11y.rule-title"
+title = "Rule: title shown in the window"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "rule title: Info Title and DisplayDocTitle; Fix sets both"
+
+[[feature]]
+id = "a11y.rule-bookmarks"
+title = "Rule: bookmarks in long documents"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"]
+notes = "rule bookmarks: more than 20 pages need bookmarks"
+
+[[feature]]
+id = "a11y.rule-color-contrast"
+title = "Rule: colour contrast (manual)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "rule color-contrast: needs manual check; off by default as in Acrobat"
+
+[[feature]]
+id = "a11y.rule-tagged-content"
+title = "Rule: all content tagged or artifact"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
+notes = "rule tagged-content: painting outside MCID or Artifact marked content, through form XObjects; counted per page"
+
+[[feature]]
+id = "a11y.rule-tagged-annotations"
+title = "Rule: tagged annotations"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
+notes = "rule tagged-annotations: annotations other than popups, widgets and multimedia need /StructParent"
+
+[[feature]]
+id = "a11y.rule-tab-order"
+title = "Rule: tab order follows structure"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
+notes = "rule tab-order: pages with annotations need /Tabs /S; Fix sets it on every page"
+
+[[feature]]
+id = "a11y.rule-character-encoding"
+title = "Rule: reliable character encoding"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"]
+notes = "rule character-encoding: ToUnicode, a standard encoding, or a predefined CJK collection; each font once"
+
+[[feature]]
+id = "a11y.rule-tagged-multimedia"
+title = "Rule: tagged multimedia"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
+notes = "rule tagged-multimedia: Screen, Movie, Sound, RichMedia and 3D annotations need /StructParent"
+
+[[feature]]
+id = "a11y.rule-screen-flicker"
+title = "Rule: screen flicker (manual)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
+notes = "rule screen-flicker: needs manual check"
+
+[[feature]]
+id = "a11y.rule-scripts"
+title = "Rule: scripts (manual)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "rule scripts: needs manual check"
+
+[[feature]]
+id = "a11y.rule-timed-responses"
+title = "Rule: timed responses (manual)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
+notes = "rule timed-responses: needs manual check"
+
+[[feature]]
+id = "a11y.rule-navigation-links"
+title = "Rule: navigation links (manual)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
+notes = "rule navigation-links: needs manual check"
+
+[[feature]]
+id = "a11y.rule-tagged-fields"
+title = "Rule: tagged form fields"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"]
+notes = "rule tagged-form-fields: widgets need /StructParent"
+
+[[feature]]
+id = "a11y.rule-field-descriptions"
+title = "Rule: field descriptions"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"]
+notes = "rule field-descriptions: fields need a tooltip (/TU)"
+
+[[feature]]
+id = "a11y.rule-figure-alt"
+title = "Rule: figures have alternate text"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule figures-alt-text: Figure elements need Alt or ActualText (or an ancestor's)"
+
+[[feature]]
+id = "a11y.rule-nested-alt"
+title = "Rule: no nested alternate text"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule nested-alt-text: Alt inside an element with Alt"
+
+[[feature]]
+id = "a11y.rule-alt-associated"
+title = "Rule: alternate text associated with content"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule alt-text-associated: Alt on an element with no content beneath it"
+
+[[feature]]
+id = "a11y.rule-alt-hides-annot"
+title = "Rule: alternate text does not hide annotations"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule alt-text-hides-annotation: Alt on an element containing an OBJR"
+
+[[feature]]
+id = "a11y.rule-other-alt"
+title = "Rule: other elements have alternate text"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule other-elements-alt-text: Formula elements need Alt"
+
+[[feature]]
+id = "a11y.rule-table-rows"
+title = "Rule: table rows"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule table-rows: TR parent is Table, THead, TBody or TFoot"
+
+[[feature]]
+id = "a11y.rule-table-cells"
+title = "Rule: TH and TD in rows"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule table-cells: TH/TD parent is TR"
+
+[[feature]]
+id = "a11y.rule-table-headers"
+title = "Rule: tables have headers"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule table-headers: at least one TH"
+
+[[feature]]
+id = "a11y.rule-table-regularity"
+title = "Rule: table regularity"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::row_and_column_spans_keep_a_table_regular"]
+notes = "rule table-regularity: equal row widths counting ColSpan and RowSpan"
+
+[[feature]]
+id = "a11y.rule-table-summary"
+title = "Rule: table summary"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule table-summary: Summary table attribute"
+
+[[feature]]
+id = "a11y.rule-list-items"
+title = "Rule: list items"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule list-items: LI parent is L"
+
+[[feature]]
+id = "a11y.rule-lbl-lbody"
+title = "Rule: Lbl and LBody"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule lbl-lbody: Lbl/LBody parent is LI (TOCI and notes allowed)"
+
+[[feature]]
+id = "a11y.rule-heading-nesting"
+title = "Rule: heading nesting"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.check"]
+tools = ["accessibility_check"]
+evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"]
+notes = "rule heading-nesting: H1–H6 never skip a level going down; the first is H1"
+
+[[feature]]
+id = "a11y.autotag"
+title = "Automatically tag a document"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.autotag"]
+tools = ["accessibility_autotag_preview", "accessibility_autotag_apply"]
+evidence = ["crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/ui-egui/tests/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"]
+notes = "Auto-tag writes real marked content, a Document structure element, P tags and a parent tree for text-only PDFs, with undo and Tags-panel review. It refuses mixed content, forms, annotations and existing tags. The preview separately proposes headings. Images, tables, lists, links, fields and headings are not classified or tagged by Apply."
+
+[[feature]]
+id = "a11y.autotag-fields"
+title = "Autotag form fields"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.autotag_fields"]
+tools = ["accessibility_autotag_form_fields"]
+evidence = ["crates/a11y/src/tests.rs::autotag_form_fields_writes_widget_structure_and_parent_tree", "crates/automation/tests/automation.rs::autotag_form_fields_writes_reviewable_widget_tags"]
+notes = "For an untagged AcroForm, writes Form structure elements, OBJR references, widget StructParent keys and ParentTree entries. An existing tree is extended when its root points directly to a Document element and its ParentTree uses a direct Nums array; other layouts are refused before any edit. A widget listed on several pages is tagged once. Text, figures, links and other content remain untagged."
+
+[[feature]]
+id = "a11y.reading-order-tool"
+title = "Reading order tool"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = []
+tools = ["accessibility_move_tag"]
+evidence = ["crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "The headless tool can move a reachable indirect tag before or after a direct sibling in their parent's /K array as one undoable edit. It preserves page content and rejects ambiguous children. The visual Reading Order tool, inline tags, arbitrary retagging, and moving across parents remain planned."
+
+[[feature]]
+id = "a11y.tags-panel"
+title = "Tags panel (new, delete, find, change to artifact)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.tags"]
+tools = ["accessibility_tags"]
+evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag", "crates/automation/tests/automation.rs::structure_tags_are_listed_a_page_at_a_time"]
+notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. accessibility_tags lists them a page at a time (offset, limit, next) and can keep only some types, pages or depths. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982."
+
+[[feature]]
+id = "a11y.tag-properties"
+title = "Tag properties (type, title, actual text, alternate text, language)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.tags"]
+tools = ["accessibility_tags", "accessibility_set_tag_property"]
+evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/a11y/src/tests.rs::tag_hierarchy_keeps_indirect_parent_through_inline_tags", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
+notes = "The engine, headless tools and right-side editor list reachable indirect tags with parent and depth and edit type, title, actual text, alternate text and language with undo. Inline tag dictionaries and structural operations remain planned with the Tags Panel (#982)."
+
+[[feature]]
+id = "a11y.role-map"
+title = "Role map editor"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.class-map"
+title = "Class map editor"
+area = "M"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.order-panel"
+title = "Order panel"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.tags"]
+tools = ["accessibility_move_tag"]
+evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_moves_a_tag_within_its_siblings", "crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "The Tags panel moves a selected reachable indirect tag earlier or later among direct siblings, preserving page appearance and making one undoable reading-order edit. It does not yet show page-content order visually, edit direct tag dictionaries, move across parents, or reorder arbitrary content."
+
+[[feature]]
+id = "a11y.content-panel"
+title = "Content panel"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.tags"]
+tools = ["accessibility_tag_content"]
+evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
+notes = "Selecting a tag in the Tags panel lists its direct marked-content references with MCID, page and extracted text, and selecting an item navigates to the page. The content tree is presented within the Tags panel; editing content objects and arbitrary inline structure nodes remain planned."
+
+[[feature]]
+id = "a11y.create-tag-from-selection"
+title = "Create a tag from a selection"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.artifact-marking"
+title = "Mark content as artifact"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.tags"]
+tools = ["accessibility_mark_tag_artifact"]
+evidence = ["crates/a11y/src/tests.rs::marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity", "crates/automation/tests/automation.rs::marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved", "crates/ui-egui/tests/accessibility.rs::tags_panel_marks_a_leaf_tag_as_an_artifact"]
+notes = "Tags panel and automation can retire a reachable indirect leaf tag that owns direct MCIDs: its visible page content remains but becomes /Artifact and the parent-tree references are cleared. Tags with structural children, non-MCID content, missing pages or ambiguous parents are refused. Arbitrary painted-content selection, direct structure dictionaries and removing whole tagged subtrees remain unsupported."
+
+[[feature]]
+id = "a11y.find-untagged"
+title = "Find unmarked content, comments and links"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "partial"
+commands = ["a11y.find_untagged"]
+tools = ["accessibility_find_untagged"]
+evidence = ["crates/automation/tests/automation.rs::finding_untagged_content_limits_results_to_content_annotations_and_widgets", "crates/ui-egui/tests/accessibility.rs::finding_untagged_content_shows_the_focused_accessibility_results"]
+notes = "Lists findings for untagged painted page content, non-widget annotations and form widgets, optionally on selected pages. It reuses the checker and does not identify untagged table structure, links already represented by an annotation, or structure-tree elements that lack content."
+
+[[feature]]
+id = "a11y.table-editor"
+title = "Table editor (headers, scope, spans)"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.alt-text-workflow"
+title = "Set alternate text figure by figure (with decorative option)"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["a11y.alt_text"]
+tools = ["accessibility_figures", "accessibility_set_alt"]
+evidence = ["crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::setting_alt_text_and_marking_decorative", "crates/ui-egui/tests/accessibility.rs::setting_alternate_text_figure_by_figure", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
+notes = "Set Alternate Text dialog: each tagged figure (role map honoured) with a preview of where it is drawn, its alternate text, Decorative figure (content becomes /Artifact, element leaves the tags and parent tree), Previous/Next, Save & Close as one undo step"
+
+[[feature]]
+id = "a11y.alt-text-suggestions"
+title = "Suggest alternate text via an AI provider"
+area = "M"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "optional provider; off by default"
+
+[[feature]]
+id = "a11y.set-language"
+title = "Set document language"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_initial_view"]
+evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open"]
+notes = "Document Properties ▸ Initial View ▸ Language (catalog /Lang)"
+
+[[feature]]
+id = "a11y.set-title-display"
+title = "Show the document title in the window"
+area = "M"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["file.properties"]
+tools = ["doc_initial_view"]
+evidence = ["crates/automation/tests/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/viewer.rs::tab_and_window_show_the_document_title_when_asked"]
+notes = "Initial View ▸ Show: Document Title (/DisplayDocTitle); the tab and window then show the title"
+
+[[feature]]
+id = "a11y.make-accessible-action"
+title = "Make Accessible guided action"
+area = "M"
+tier = "P1"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "Headless review sidecar tools save and reopen human decisions against the exact working PDF SHA-256. The guided GUI action and step-by-step remediation are still missing."
+
+[[feature]]
+id = "a11y.setup-assistant"
+title = "Accessibility setup assistant"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "Review decisions can be saved and reopened through accessibility_review_save and accessibility_review_load. A GUI assistant is still missing."
+
+[[feature]]
+id = "a11y.read-aloud"
+title = "Read out loud (platform TTS)"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.read-aloud-options"
+title = "Read-aloud voice, pitch and speed"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.replace-page-colors"
+title = "Replace page colours (high contrast)"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "shipped"
+commands = []
+tools = ["view_reading_colors"]
+evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"]
+notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)."
+
+[[feature]]
+id = "a11y.reflow-tagged"
+title = "Reflow tagged content"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "a11y.keyboard-selection-cursor"
+title = "Always show the keyboard selection cursor"
+area = "M"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+# ==================================================================================================
+# N. Compare, automation, AI, preferences, misc
+# ==================================================================================================
+
+[[feature]]
+id = "misc.compare-text"
+title = "Compare files: text differences"
+area = "N"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["doc.compare"]
+tools = ["doc_compare", "doc_compare_mark"]
+evidence = ["crates/compare/src/lib.rs::insertions_deletions_and_replacements", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"]
+notes = "Word-level Myers diff with patience anchors for long files; replaced/inserted/deleted shaded on the new file, listed in the Compare panel, or added as comments."
+
+[[feature]]
+id = "misc.compare-visual"
+title = "Compare files: visual differences"
+area = "N"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["doc.compare"]
+tools = ["doc_compare"]
+evidence = ["crates/compare/src/lib.rs::finds_changed_regions", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools"]
+notes = "Pages rendered at 72 dpi and compared page by page; differing regions shaded orange where no text change explains them."
+
+[[feature]]
+id = "misc.compare-side-by-side"
+title = "Side-by-side compare view with synchronised scrolling"
+area = "N"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["doc.compare"]
+tools = []
+evidence = ["crates/ui-egui/tests/compare.rs::compare_two_versions"]
+notes = "After Compare files the document area splits (when the old version is open in a tab and the window is at least 1000 px wide; the Compare panel's Side by side / New file only toggles it): the old file on the left, the new on the right, each a page view of its own with its shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half the pointer is over leads: the other takes its zoom, fit, layout, rotation and scroll offset; a change clicked in the panel jumps both halves to their side of it. Clear ends it. Still missing against Acrobat: scrolling by matching content rather than by offset (pages of different sizes drift), and the comparison kept when the old file's tab closes."
+
+[[feature]]
+id = "misc.compare-report"
+title = "Compare summary report as PDF"
+area = "N"
+tier = "P1"
+milestone = "M12"
+status = "shipped"
+commands = ["doc.compare"]
+tools = ["doc_compare_report"]
+evidence = ["crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"]
+notes = "Summary report as a new PDF (counts and every change with pages)."
+
+[[feature]]
+id = "misc.compare-filters"
+title = "Filter changes by type (text, images, formatting)"
+area = "N"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.compare-page-ranges"
+title = "Compare page ranges"
+area = "N"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.compare-scanned"
+title = "Compare scanned documents via OCR"
+area = "N"
+tier = "P2"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.action-wizard-run"
+title = "Run guided actions on files"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "shipped"
+commands = ["actions.wizard"]
+tools = ["action_list", "action_run"]
+evidence = ["crates/ui-egui/tests/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
+notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress."
+
+[[feature]]
+id = "misc.action-wizard-create"
+title = "Create and edit actions"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "shipped"
+commands = ["actions.wizard"]
+tools = ["action_run"]
+evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
+notes = "New/Edit/Delete actions with 12 step kinds (OCR, reduce size, remove hidden information, sanitize, flatten, watermark, header, footer, title, detect fields, JavaScript); remembered between runs."
+
+[[feature]]
+id = "misc.action-wizard-manage"
+title = "Import, export, copy and delete actions"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.action-prepare-distribution"
+title = "Built-in action: prepare for distribution"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "shipped"
+commands = ["actions.distribution"]
+tools = ["action_run"]
+evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
+notes = "Remove hidden information, flatten comments and fields."
+
+[[feature]]
+id = "misc.action-optimize-scans"
+title = "Built-in action: optimize scanned documents"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "shipped"
+commands = ["actions.optimize_scans"]
+tools = ["action_run"]
+evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools"]
+notes = "Recognize text, then reduce file size."
+
+[[feature]]
+id = "misc.action-archive"
+title = "Built-in action: archive documents"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.action-publish-web"
+title = "Built-in action: prepare for web publishing"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.batch-processing"
+title = "Batch processing over files and folders"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "partial"
+commands = ["actions.wizard", "ocr.recognize_batch"]
+tools = ["action_run", "ocr_recognize_files", "doc_batch"]
+evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions", "crates/automation/tests/batch.rs::batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs", "apps/pdfcraft-cli/tests/batch.rs::cli_batch_reports_partial_failure_and_continues_into_subfolders"]
+notes = "Actions and OCR run over chosen files. CLI/MCP doc_batch adds bounded recursive folders for convert/ocr/optimize recipes, retained relative paths, collision-safe outputs and per-file reports. GUI folder selection, generic action recipes, scheduling and resume remain absent. See docs/cli-batch.md."
+
+[[feature]]
+id = "misc.action-prompt-steps"
+title = "Prompt-user and instruction steps"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.action-javascript-step"
+title = "Execute JavaScript step"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.custom-commands"
+title = "Custom commands"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.custom-tools"
+title = "Custom tool sets"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.command-registry"
+title = "One command registry for menus, shortcuts, palette and automation"
+area = "N"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = ["command_list"]
+evidence = ["crates/engine/src/commands.rs::ids_and_shortcuts_are_unique", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"]
+notes = ""
+
+[[feature]]
+id = "misc.disabled-command-reasons"
+title = "Disabled commands explain why"
+area = "N"
+tier = "P1"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/commands.rs::disabled_commands_explain_themselves"]
+notes = ""
+
+[[feature]]
+id = "misc.cli-inspect"
+title = "CLI: inspect, render and extract text"
+area = "N"
+tier = "P0"
+milestone = "M2"
+status = "shipped"
+commands = []
+tools = ["doc_info", "page_render", "text_extract"]
+evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find"]
+notes = ""
+
+[[feature]]
+id = "misc.cli-edit"
+title = "CLI: edit, combine, extract, split"
+area = "N"
+tier = "P0"
+milestone = "M4"
+status = "shipped"
+commands = []
+tools = ["page_rotate", "page_delete", "doc_combine", "page_extract", "doc_split"]
+evidence = ["crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip"]
+notes = ""
+
+[[feature]]
+id = "misc.cli-run"
+title = "CLI run: call any automation tool or a JSON script"
+area = "N"
+tier = "P0"
+milestone = "M13"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed", "apps/pdfcraft-cli/tests/run_report.rs::a_failing_step_is_named_and_the_rest_are_skipped"]
+notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors. `run --script --report` prints one JSON document with a status per step (#1891); without it the output is unchanged."
+
+[[feature]]
+id = "misc.cli-completeness"
+title = "CLI covers every shipped command"
+area = "N"
+tier = "P0"
+milestone = "M13"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/automation/tests/automation.rs::command_list_reports_enablement_and_tools"]
+notes = "tools grow as features land"
+
+[[feature]]
+id = "misc.automation-tools"
+title = "Headless JSON-Schema tool table"
+area = "N"
+tier = "P0"
+milestone = "M13"
+status = "shipped"
+commands = []
+tools = ["command_list"]
+evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::errors_are_specific_and_safe"]
+notes = ""
+
+[[feature]]
+id = "misc.command-list-tool"
+title = "List commands with enablement and their tools"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "shipped"
+commands = []
+tools = ["command_list"]
+evidence = ["crates/automation/tests/automation.rs::command_list_reports_enablement_and_tools"]
+notes = ""
+
+[[feature]]
+id = "misc.mcp-server"
+title = "Opt-in MCP server over stdio"
+area = "N"
+tier = "P0"
+milestone = "M13"
+status = "shipped"
+commands = []
+tools = ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"]
+evidence = ["crates/automation/tests/automation.rs::mcp_session_over_stdio", "crates/automation/tests/automation.rs::mcp_errors", "crates/automation/tests/automation.rs::conventions_core_tools_preserve_root_and_batch_errors", "crates/automation/tests/automation.rs::conventions_annotations_and_strict_keys_in_both_modes"]
+notes = ""
+
+[[feature]]
+id = "misc.automation-root-confinement"
+title = "Confine automation file access to a root directory"
+area = "N"
+tier = "P0"
+milestone = "M13"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/automation/tests/automation.rs::errors_are_specific_and_safe", "crates/automation/tests/automation.rs::root_refusals_do_not_reveal_what_exists_outside", "apps/pdfcraft-cli/tests/run_root.rs::script_image_output_is_confined_to_the_root", "crates/automation/tests/automation.rs::file_names_from_documents_stay_in_the_output_folder"]
+notes = "One refusal for every path outside the root, existing or not (#136). `run --script` images (`out`) are confined too (#137). Check-then-write, not openat-style: a process racing the tool on the same disk could swap a folder for a link between the check and the write."
+
+[[feature]]
+id = "misc.mcp-resources"
+title = "MCP resources for page images and text"
+area = "N"
+tier = "P1"
+milestone = "M13"
+status = "shipped"
+commands = []
+tools = ["doc_info", "text_extract", "page_render"]
+evidence = ["crates/automation/tests/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse", "crates/automation/tests/automation.rs::conventions_resources"]
+notes = "resources/list, resources/templates/list and resources/read: pdfcraft://doc/{doc}/info (JSON), /text, /page/{page}/text, /page/{page}/image?dpi= (PNG)"
+
+[[feature]]
+id = "misc.ui-control-channel"
+title = "UI control channel (inspect, click, type, keys, commands)"
+area = "N"
+tier = "P0"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/control.rs::commands_keys_and_typing", "crates/ui-egui/tests/control.rs::loopback_transport_requires_the_token"]
+notes = ""
+
+[[feature]]
+id = "misc.ui-screenshots"
+title = "Screenshots of the window or a region via the control channel"
+area = "N"
+tier = "P1"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/control.rs::screenshots_of_window_and_region"]
+notes = ""
+
+[[feature]]
+id = "misc.ai-provider"
+title = "AI provider interface (local model or own endpoint), off by default"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.ai-summary"
+title = "Summarize a document"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.ai-ask"
+title = "Ask questions with citations to pages"
+area = "N"
+tier = "P2"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.ai-conversational-edit"
+title = "Natural-language editing through registry commands"
+area = "N"
+tier = "P3"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.ai-presentation"
+title = "Generate a presentation from documents"
+area = "N"
+tier = "P3"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.ai-podcast"
+title = "Generate an audio overview"
+area = "N"
+tier = "P3"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.ai-charts"
+title = "Charts from document data"
+area = "N"
+tier = "P3"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.contract-analysis"
+title = "Contract key terms and multi-contract compare"
+area = "N"
+tier = "P3"
+milestone = "M13"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.adobe-ai-assistant"
+title = "Adobe AI Assistant"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; replaced by the optional local/BYO AiProvider"
+
+[[feature]]
+id = "misc.pdf-spaces"
+title = "PDF Spaces"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; no local equivalent planned"
+
+[[feature]]
+id = "misc.adobe-express"
+title = "Adobe Express integration and generated images"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud"
+
+[[feature]]
+id = "misc.document-cloud-storage"
+title = "Adobe Document Cloud storage"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; local files and native dialogs (pluggable storage later)"
+
+[[feature]]
+id = "misc.share-links"
+title = "Share links and invitations"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; pluggable share provider"
+
+[[feature]]
+id = "misc.connected-storage"
+title = "Connected third-party storage accounts in-app"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe cloud; use OS file dialogs and synced folders, which can be pinned to Home (view.home-view)"
+
+[[feature]]
+id = "misc.cross-device-sync"
+title = "Sync signatures, stamps and preferences across devices"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Adobe account service"
+
+[[feature]]
+id = "misc.adobe-account"
+title = "Adobe account sign-in"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "not applicable; no account needed"
+
+[[feature]]
+id = "misc.rich-media-preserve"
+title = "Preserve 3D, video and sound content"
+area = "N"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.rich-media-poster"
+title = "Show posters for rich media"
+area = "N"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.rich-media-playback"
+title = "Play video and sound via the platform"
+area = "N"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.3d-view"
+title = "View 3D (U3D/PRC) content"
+area = "N"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.legacy-multimedia"
+title = "Legacy multimedia (screen annotations, renditions)"
+area = "N"
+tier = "P3"
+milestone = "M12"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.flash-swf"
+title = "Flash/SWF content"
+area = "N"
+tier = "N/A"
+milestone = "none"
+status = "na"
+commands = []
+tools = []
+evidence = []
+notes = "Flash is end-of-life; content is preserved but not played"
+
+[[feature]]
+id = "misc.preferences-dialog"
+title = "Preferences dialog generated from a schema"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "partial"
+commands = ["app.preferences"]
+tools = ["js_enabled"]
+evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override", "apps/pdfcraft/src/settings.rs::xdg_preferences_use_config_instead_of_data", "apps/pdfcraft/src/settings.rs::migration_preserves_preferences_and_other_data"]
+notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet. XDG desktops store app.ron under XDG_CONFIG_HOME/pdfcraft or ~/.config/pdfcraft; existing data-folder preferences are copied without replacing configured preferences, with fallback on migration errors. Logs and recovery retain data locations. Portable/non-XDG locations are unchanged."
+
+[[feature]]
+id = "misc.prefs-general"
+title = "Preferences: general"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-page-display"
+title = "Preferences: page display"
+area = "N"
+tier = "P1"
+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"
+
+[[feature]]
+id = "misc.prefs-documents"
+title = "Preferences: documents (autosave interval, recent count)"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-commenting"
+title = "Preferences: commenting"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-forms"
+title = "Preferences: forms"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-identity"
+title = "Preferences: identity"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-full-screen"
+title = "Preferences: full screen"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-search"
+title = "Preferences: search"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-signatures"
+title = "Preferences: signatures"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-security"
+title = "Preferences: security and trust"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-spelling"
+title = "Preferences: spelling"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-language"
+title = "Preferences: language"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "partial"
+commands = ["app.preferences"]
+tools = []
+evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"]
+notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish and Italian translate them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit."
+
+[[feature]]
+id = "misc.prefs-reading"
+title = "Preferences: reading"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-accessibility"
+title = "Preferences: accessibility"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-color-management"
+title = "Preferences: colour management"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-javascript"
+title = "Preferences: JavaScript"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "shipped"
+commands = ["app.preferences"]
+tools = ["js_enabled"]
+evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"]
+notes = "Enable Acrobat JavaScript, remembered between runs."
+
+[[feature]]
+id = "misc.prefs-measuring"
+title = "Preferences: measuring"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-units-guides"
+title = "Preferences: units and guides"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-content-editing"
+title = "Preferences: content editing"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-convert"
+title = "Preferences: convert to and from PDF"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.prefs-catalog"
+title = "Preferences: catalog / index"
+area = "N"
+tier = "P3"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.admin-preferences"
+title = "Admin-deployable preference defaults"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.localization"
+title = "Localization (Fluent catalogs)"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "partial"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"]
+notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Chinese shows tofu in releases (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic is not mirrored (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it."
+
+[[feature]]
+id = "misc.rtl-ui"
+title = "Right-to-left user interface"
+area = "N"
+tier = "P2"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = "Only a first step exists: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop) and are shown in display order (crates/ui-egui/src/bidi.rs). Missing: multi-line text (bookmarks, comments, search results), text input, a mirrored layout and a right-to-left interface language."
+
+[[feature]]
+id = "misc.installers"
+title = "Signed installers for macOS, Windows and Linux"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "partial"
+commands = []
+tools = []
+evidence = []
+notes = "Release builds provide signed and notarized macOS artifacts and code-signed Windows installers when signing credentials are configured (packaging/macos/package.sh; packaging/windows/sign.ps1; README.md). Linux AppImage, Flatpak, deb and rpm packages ship, but are not code-signed. Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut and an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682), and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported."
+
+[[feature]]
+id = "misc.web-deploy"
+title = "Hosted web app deployment"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.performance-budgets"
+title = "Meet performance budgets on Tier-1 platforms"
+area = "N"
+tier = "P0"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.send-email"
+title = "Send the file by email (system mail client)"
+area = "N"
+tier = "P3"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
+
+[[feature]]
+id = "misc.user-docs"
+title = "User documentation and keyboard reference"
+area = "N"
+tier = "P1"
+milestone = "M14"
+status = "planned"
+commands = []
+tools = []
+evidence = []
+notes = ""
From 2f53d0f0e29c9f29633418d662bccefb1fe5f8da Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Sat, 17 Oct 2026 10:12:27 +0530
Subject: [PATCH 020/289] Chore:Fixed Cl cargo test -p pdfcraft-automation
---
crates/automation/tests/automation.rs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 1ebb8cc2d4..8187478986 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -716,12 +716,11 @@ fn an_in_place_save_reports_a_required_rewrite_as_not_incremental() {
#[test]
fn a_recovered_page_tree_survives_a_full_save() {
let dir = workdir("save-verify-before-write");
- let objs: [&[u8]; 5] = [
+ let objs: [&[u8]; 4] = [
b"<< /Type /Catalog /Pages 3 0 R >>",
b"<< /Type /Page /MediaBox [0 0 200 300] /Contents 4 0 R >>",
b"[ 0 ]",
b"<< /Length 18 >>\nstream\n0 0 m 100 100 l S\nendstream",
- b"<< /Type /Page /MediaBox [0 0 200 200] >>",
];
let mut original = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
From 1f728fda8f56fc88e5ff487315b20cde6c50b265 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Sat, 17 Oct 2026 17:52:18 +0530
Subject: [PATCH 021/289] fix: resolve stale clipboard image paste bug &
finalized CI checks
---
crates/render/src/pixels.rs | 1 +
crates/render/src/raster.rs | 5 +++++
crates/ui-egui/src/editing.rs | 1 +
crates/ui-egui/src/lib.rs | 22 ++++++++++++++++++++++
crates/ui-egui/src/units.rs | 1 +
crates/ui-egui/src/updates.rs | 1 +
crates/ui-egui/tests/editing.rs | 7 +++++++
7 files changed, 38 insertions(+)
diff --git a/crates/render/src/pixels.rs b/crates/render/src/pixels.rs
index 1fc1128616..a2a8b423b0 100644
--- a/crates/render/src/pixels.rs
+++ b/crates/render/src/pixels.rs
@@ -26,6 +26,7 @@ impl Pixels {
}
/// Bytes the buffer holds allocated (its capacity), for memory budgets.
+ #[allow(dead_code)]
pub(crate) fn byte_capacity(&self) -> usize {
self.0.capacity().saturating_mul(4)
}
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index 8d5686dd8c..2b5774ef48 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -244,6 +244,7 @@ struct AtomicRenderStats {
}
impl AtomicRenderStats {
+ #[allow(dead_code)]
fn record_request(&self, req: RenderRequest) {
self.render_requests.fetch_add(1, Ordering::Relaxed);
if req.tile.is_some() {
@@ -541,6 +542,7 @@ struct Shared {
busy: Mutex>>,
/// Exact requests the watchdog gave up on: answered with an error at once, so a pathological
/// request cannot trap every worker in turn while other scales, tiles, or generations remain usable.
+ #[allow(dead_code)]
stuck: Mutex>,
stats: AtomicRenderStats,
/// Per worker id: set to stop that worker's render at its next content operator once nobody
@@ -715,8 +717,11 @@ pub struct RenderPool {
shared: Arc,
wake: Mutex>>,
results: ResultReceiver,
+ #[allow(dead_code)]
results_tx: Arc,
+ #[allow(dead_code)]
bytes: Arc>,
+ #[allow(dead_code)]
config: RenderConfig,
_workers: Mutex>>,
stuck_after: std::time::Duration,
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index be1347d68d..7c2553cdf9 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -974,6 +974,7 @@ fn is_form_edit(edit: &Edit) -> bool {
/// because Outlook can still hold the file, or appears to work while the edits stay out of the
/// email and are deleted with the cache. Matched on either separator and in any case, so it can
/// be tested on every platform.
+#[allow(dead_code)]
pub(crate) fn is_mail_temp_copy(path: &str) -> bool {
let p = path.replace('\\', "/").to_ascii_lowercase();
p.contains("/inetcache/content.outlook/")
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 283446d048..ba8fd6390d 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2922,6 +2922,12 @@ impl PdfCraftApp {
|| 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_event_text = ctx.input(|input| {
+ input.events.iter().find_map(|e| match e {
+ egui::Event::Paste(text) if !text.is_empty() => Some(text.clone()),
+ _ => None,
+ })
+ });
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)));
@@ -2944,6 +2950,22 @@ impl PdfCraftApp {
self.cut_page_image(sel.page, sel.index);
}
if paste {
+ #[cfg(not(target_arch = "wasm32"))]
+ let system_has_text = if self.system_clipboard {
+ arboard::Clipboard::new().and_then(|mut cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false)
+ } else {
+ false
+ };
+ #[cfg(target_arch = "wasm32")]
+ let system_has_text = false;
+
+ if paste_event_text.is_some() || system_has_text {
+ self.last_copied_image = None;
+ self.last_copied_file = None;
+ self.last_copied_image_rect = None;
+ return;
+ }
+
let page = self.views.get(i).map_or(0, |v| v.current);
self.paste_image(page, None);
}
diff --git a/crates/ui-egui/src/units.rs b/crates/ui-egui/src/units.rs
index a345456fab..c25065c6f2 100644
--- a/crates/ui-egui/src/units.rs
+++ b/crates/ui-egui/src/units.rs
@@ -68,6 +68,7 @@ impl Unit {
/// The unit for a locale tag's region: `en_US.UTF-8`, `en-US`, `bg-BG`, `zh-Hans-CN`, `en_GB@euro`.
/// `None` without a region (`C`, `POSIX`, `en`).
+#[allow(dead_code)]
pub(crate) fn unit_for_tag(tag: &str) -> Option {
let tag = tag.split(['.', '@']).next().unwrap_or_default();
let region = tag.split(['-', '_']).skip(1).filter(|p| p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())).last()?;
diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs
index 4e7957e15e..73a6168386 100644
--- a/crates/ui-egui/src/updates.rs
+++ b/crates/ui-egui/src/updates.rs
@@ -52,6 +52,7 @@ pub(crate) enum Check {
Idle,
#[cfg(not(target_arch = "wasm32"))]
Running(std::sync::mpsc::Receiver>),
+ #[allow(dead_code)]
Done(Result),
}
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index e542f9b737..a9c3dca38a 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -4985,11 +4985,18 @@ fn stale_clipboard_plain_text_does_not_paste_image() {
cb.set_text("just plain text").ok();
}
+ // A paste event containing plain text clears internal image cache and does not paste image
+ h.event(egui::Event::Paste("just plain text".into()));
+ h.run_steps(2);
+
// 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");
+
+ let doc = h.state().session.get(h.state().views[0].id).unwrap();
+ assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page");
}
#[test]
From ab6a4109a841648b5c5b751b70c1d27ef6e83628 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Sat, 17 Oct 2026 20:34:06 +0530
Subject: [PATCH 022/289] ci: run tests sequentially to prevent Windows
clipboard crash
---
.github/workflows/ci.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 388b44f032..7eca1256b0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,11 +85,11 @@ jobs:
- name: Test
if: runner.os != 'Linux'
- run: cargo test --workspace
+ run: cargo test --workspace -- --test-threads=1
- name: Test (with craft-fonts)
if: runner.os == 'Linux'
- run: cargo test --workspace
+ run: cargo test --workspace -- --test-threads=1
- name: Layering
if: runner.os == 'Linux'
From c4e625f0ec51d42f807702a9470b8d98589f5f08 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Sat, 17 Oct 2026 21:29:55 +0530
Subject: [PATCH 023/289] test: implement deterministic mock clipboard to
resolve Windows heap corruption
---
.github/workflows/ci.yml | 4 +-
apps/pdfcraft/src/main.rs | 2 +
crates/ui-egui/src/clipboard.rs | 38 +++++++++++++++++
crates/ui-egui/src/content_ui.rs | 71 +++++++++++++++++++++++---------
crates/ui-egui/src/create_ui.rs | 22 ++++++++--
crates/ui-egui/src/image_pick.rs | 2 +-
crates/ui-egui/src/lib.rs | 47 ++++++++++++++++++---
crates/ui-egui/src/zoom_snap.rs | 13 ++++--
crates/ui-egui/tests/editing.rs | 26 ++++++++++--
9 files changed, 186 insertions(+), 39 deletions(-)
create mode 100644 crates/ui-egui/src/clipboard.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7eca1256b0..388b44f032 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,11 +85,11 @@ jobs:
- name: Test
if: runner.os != 'Linux'
- run: cargo test --workspace -- --test-threads=1
+ run: cargo test --workspace
- name: Test (with craft-fonts)
if: runner.os == 'Linux'
- run: cargo test --workspace -- --test-threads=1
+ run: cargo test --workspace
- name: Layering
if: runner.os == 'Linux'
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 7a0bb20432..5af9fa3fc0 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -368,6 +368,8 @@ fn app_creator<'a>(
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
+ app.system_clipboard = true;
+ app.clipboard_override = None;
// First start (or settings from before Units existed): the region's unit.
app.units = pdfcraft_ui_egui::units::Unit::system();
#[cfg(target_os = "linux")]
diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs
new file mode 100644
index 0000000000..a30304fa66
--- /dev/null
+++ b/crates/ui-egui/src/clipboard.rs
@@ -0,0 +1,38 @@
+//! Deterministic in-memory clipboard for headless UI tests and automation.
+//!
+//! Prevents headless tests running in parallel from competing for the native
+//! operating system clipboard, eliminating concurrency races and OS heap corruption.
+
+use std::path::PathBuf;
+
+/// A deterministic, in-memory mock clipboard for tests.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct MockClipboard {
+ pub text: Option,
+ pub image: Option<(usize, usize, Vec)>,
+ pub files: Option>,
+}
+
+impl MockClipboard {
+ /// Creates an empty mock clipboard.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Creates a mock clipboard containing plain text.
+ pub fn with_text(text: impl Into) -> Self {
+ Self { text: Some(text.into()), image: None, files: None }
+ }
+
+ /// Creates a mock clipboard containing an image (width, height, RGBA bytes).
+ pub fn with_image(width: usize, height: usize, rgba: Vec) -> Self {
+ Self { text: None, image: Some((width, height, rgba)), files: None }
+ }
+
+ /// Clears all clipboard contents.
+ pub fn clear(&mut self) {
+ self.text = None;
+ self.image = None;
+ self.files = None;
+ }
+}
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 61be108256..c6d914d243 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -821,14 +821,19 @@ impl crate::PdfCraftApp {
return false;
};
let copied = pixels.and_then(|(w, h, rgba)| {
- #[cfg(not(target_arch = "wasm32"))]
- if self.system_clipboard {
- let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) };
- arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
- }
- #[cfg(target_arch = "wasm32")]
- if self.system_clipboard {
- return Err("the web version can't put images on the clipboard yet; use Save Image As".to_string());
+ if let Some(mock) = &mut self.clipboard_override {
+ mock.image = Some((w as usize, h as usize, rgba.clone()));
+ mock.text = None;
+ } else {
+ #[cfg(not(target_arch = "wasm32"))]
+ if self.system_clipboard {
+ let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) };
+ arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
+ }
+ #[cfg(target_arch = "wasm32")]
+ if self.system_clipboard {
+ return Err("the web version can't put images on the clipboard yet; use Save Image As".to_string());
+ }
}
self.last_copied_image = Some((w, h, rgba));
Ok((w, h))
@@ -895,11 +900,25 @@ 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 let Some(mock) = &self.clipboard_override {
+ if mock.text.as_ref().is_some_and(|t| !t.is_empty()) {
+ return false;
+ }
+ if mock.image.is_some() {
+ return true;
+ }
+ return self.last_copied_image.is_some() || self.last_copied_file.is_some();
+ }
#[cfg(not(target_arch = "wasm32"))]
{
if self.system_clipboard
&& let Ok(mut cb) = arboard::Clipboard::new()
{
+ if let Ok(text) = cb.get_text()
+ && !text.is_empty()
+ {
+ return false;
+ }
return cb.get_image().is_ok();
}
}
@@ -918,19 +937,33 @@ impl crate::PdfCraftApp {
return false;
}
- #[cfg(not(target_arch = "wasm32"))]
- let (has_system_cb, os_image) = if self.system_clipboard {
- if let Ok(mut cb) = arboard::Clipboard::new() {
- let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec()));
- (true, img_opt)
- } else {
- (false, None)
- }
+ let (has_system_cb, os_image, system_has_text) = if let Some(mock) = &self.clipboard_override {
+ let has_text = mock.text.as_ref().is_some_and(|t| !t.is_empty());
+ let img = mock.image.clone();
+ (true, img, has_text)
} else {
- (false, None)
+ #[cfg(not(target_arch = "wasm32"))]
+ if self.system_clipboard {
+ if let Ok(mut cb) = arboard::Clipboard::new() {
+ let has_text = cb.get_text().map(|t| !t.is_empty()).unwrap_or(false);
+ let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec()));
+ (true, img_opt, has_text)
+ } else {
+ (false, None, false)
+ }
+ } else {
+ (false, None, false)
+ }
+ #[cfg(target_arch = "wasm32")]
+ (false, None, false)
};
- #[cfg(target_arch = "wasm32")]
- let (has_system_cb, os_image): (bool, Option<(usize, usize, Vec)>) = (false, None);
+
+ if system_has_text {
+ self.last_copied_image = None;
+ self.last_copied_file = None;
+ self.last_copied_image_rect = None;
+ return false;
+ }
let image_data: Option<(String, Vec)> = if has_system_cb {
match os_image {
diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs
index fb8f1b2e27..e1ecbbe1f1 100644
--- a/crates/ui-egui/src/create_ui.rs
+++ b/crates/ui-egui/src/create_ui.rs
@@ -111,10 +111,24 @@ impl PdfCraftApp {
/// Create ▸ Clipboard: a new document from the image (one page, its size) or the text on
/// the clipboard, as Acrobat does.
pub(crate) fn create_from_clipboard(&mut self) {
- #[cfg(not(target_arch = "wasm32"))]
- let clip = read_clipboard();
- #[cfg(target_arch = "wasm32")]
- let clip: Option = None;
+ let clip = if let Some(mock) = &self.clipboard_override {
+ if let Some((w, h, rgba)) = &mock.image {
+ Some(Clip::Image { width: *w, height: *h, rgba: rgba.clone() })
+ } else {
+ mock.text.as_ref().map(|t| Clip::Text(t.clone()))
+ }
+ } else if self.system_clipboard {
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ read_clipboard()
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ None
+ }
+ } else {
+ None
+ };
match clip {
Some(c) => {
if let Err(e) = self.create_from_clip(c) {
diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs
index 9e117395cd..9be0a8efc3 100644
--- a/crates/ui-egui/src/image_pick.rs
+++ b/crates/ui-egui/src/image_pick.rs
@@ -203,7 +203,7 @@ impl PdfCraftApp {
/// previous copy, and puts that file on the system clipboard.
#[cfg(not(target_arch = "wasm32"))]
fn put_file_on_clipboard(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
- if !self.system_clipboard {
+ if self.clipboard_override.is_some() || !self.system_clipboard {
return Ok(());
}
let dir = clipboard_dir().ok_or("there is no folder for the copied file")?;
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index ba8fd6390d..bc5970aef5 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -44,6 +44,8 @@ mod combine_ui;
pub mod docking;
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
mod camera_ui;
+pub mod clipboard;
+pub use clipboard::MockClipboard;
mod commands;
mod comment_props;
pub mod comments;
@@ -732,6 +734,9 @@ pub struct PdfCraftApp {
pub last_copied_file: Option<(String, Vec)>,
pub last_copied_image_rect: Option<[f64; 4]>,
pub system_clipboard: bool,
+ /// Deterministic in-memory clipboard for headless UI tests and automation.
+ /// When `Some`, clipboard operations use this mock instead of the OS clipboard.
+ pub clipboard_override: Option,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec)>,
/// Duplicate Field: which field and onto which pages.
@@ -1032,7 +1037,8 @@ impl PdfCraftApp {
last_copied_image: None,
last_copied_file: None,
last_copied_image_rect: None,
- system_clipboard: true,
+ system_clipboard: false,
+ clipboard_override: Some(MockClipboard::default()),
attach_override: None,
duplicate_draft: None,
form_preview: false,
@@ -2508,6 +2514,31 @@ impl PdfCraftApp {
}
}
+ /// Injects a deterministic mock clipboard for headless tests.
+ pub fn set_mock_clipboard(&mut self, mock: MockClipboard) {
+ self.clipboard_override = Some(mock);
+ }
+
+ /// Sets plain text on the mock clipboard (creating one if absent).
+ pub fn set_mock_clipboard_text(&mut self, text: impl Into) {
+ if let Some(mock) = &mut self.clipboard_override {
+ mock.text = Some(text.into());
+ mock.image = None;
+ } else {
+ self.clipboard_override = Some(MockClipboard::with_text(text));
+ }
+ }
+
+ /// Sets an image on the mock clipboard (creating one if absent).
+ pub fn set_mock_clipboard_image(&mut self, width: usize, height: usize, rgba: Vec) {
+ if let Some(mock) = &mut self.clipboard_override {
+ mock.image = Some((width, height, rgba));
+ mock.text = None;
+ } else {
+ self.clipboard_override = Some(MockClipboard::with_image(width, height, rgba));
+ }
+ }
+
/// `true` while any open document still waits for page renders (used by headless capture).
pub fn render_pending(&self) -> bool {
self.views.iter().any(|v| v.render_pending())
@@ -2950,14 +2981,18 @@ impl PdfCraftApp {
self.cut_page_image(sel.page, sel.index);
}
if paste {
- #[cfg(not(target_arch = "wasm32"))]
- let system_has_text = if self.system_clipboard {
- arboard::Clipboard::new().and_then(|mut cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false)
+ let system_has_text = if let Some(mock) = &self.clipboard_override {
+ mock.text.as_ref().is_some_and(|t| !t.is_empty())
} else {
+ #[cfg(not(target_arch = "wasm32"))]
+ if self.system_clipboard {
+ arboard::Clipboard::new().and_then(|mut cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false)
+ } else {
+ false
+ }
+ #[cfg(target_arch = "wasm32")]
false
};
- #[cfg(target_arch = "wasm32")]
- let system_has_text = false;
if paste_event_text.is_some() || system_has_text {
self.last_copied_image = None;
diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs
index eb2cc86727..d74627e877 100644
--- a/crates/ui-egui/src/zoom_snap.rs
+++ b/crates/ui-egui/src/zoom_snap.rs
@@ -138,10 +138,15 @@ impl PdfCraftApp {
return Err(e);
}
let (w, h) = (out.width, out.height);
- #[cfg(not(target_arch = "wasm32"))]
- if self.system_clipboard {
- let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
- arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
+ if let Some(mock) = &mut self.clipboard_override {
+ mock.image = Some((w as usize, h as usize, out.rgba.to_vec()));
+ mock.text = None;
+ } else {
+ #[cfg(not(target_arch = "wasm32"))]
+ if self.system_clipboard {
+ let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
+ arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
+ }
}
self.last_snapshot = Some((w, h, out.rgba));
Ok((w, h))
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index a9c3dca38a..02e40bdad3 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -44,6 +44,7 @@ fn harness(pages: usize, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harn
let mut h = builder.build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
+ app.clipboard_override = Some(pdfcraft_ui_egui::MockClipboard::default());
app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens");
setup(&mut app);
app
@@ -4978,11 +4979,13 @@ fn stale_clipboard_plain_text_does_not_paste_image() {
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;
+ h.state_mut().clipboard_override = None;
- // Set actual system clipboard to plain text
+ // Set actual system clipboard to plain text, asserting success explicitly
#[cfg(not(target_arch = "wasm32"))]
- if let Ok(mut cb) = arboard::Clipboard::new() {
- cb.set_text("just plain text").ok();
+ {
+ let mut cb = arboard::Clipboard::new().expect("acquire OS clipboard");
+ cb.set_text("just plain text").expect("set OS clipboard text");
}
// A paste event containing plain text clears internal image cache and does not paste image
@@ -4999,6 +5002,23 @@ fn stale_clipboard_plain_text_does_not_paste_image() {
assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page");
}
+#[test]
+fn deterministic_mock_clipboard_plain_text_does_not_paste_image() {
+ let mut h = harness(1, |_| {});
+ h.run_steps(4);
+ 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]));
+
+ // Inject plain text into the mock clipboard
+ h.state_mut().set_mock_clipboard_text("just plain text");
+
+ assert!(!h.state().can_paste_image(), "cannot paste image when mock clipboard has text");
+ assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image");
+
+ let doc = h.state().session.get(h.state().views[0].id).unwrap();
+ assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page");
+}
+
#[test]
fn rotated_image_selection_and_crop() {
let mut png = Vec::new();
From 1d2af87b847d964d537c7eb174d94207b939c8e6 Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Sat, 17 Oct 2026 21:36:48 +0530
Subject: [PATCH 024/289] test: implement deterministic mock clipboard to
resolve Windows heap corruption
---
Cargo.lock | 561 +++++++++++++++++++++++------------------------------
1 file changed, 243 insertions(+), 318 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 97135ccc3b..d07194698e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -66,7 +66,7 @@ dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
"hashbrown 0.16.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
@@ -220,21 +220,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
-version = "3.6.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
"x11rb",
]
@@ -352,13 +352,13 @@ dependencies = [
[[package]]
name = "async-recursion"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.9",
]
[[package]]
@@ -393,7 +393,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -447,9 +447,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
-version = "1.18.1"
+version = "1.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -458,9 +458,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.45.0"
+version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
dependencies = [
"cc",
"cmake",
@@ -493,15 +493,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
-[[package]]
-name = "bit-set"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
-
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -512,10 +503,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -523,6 +517,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -571,7 +575,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
dependencies = [
- "objc2 0.5.2",
+ "objc2 0.5.3",
]
[[package]]
@@ -580,7 +584,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -649,7 +653,7 @@ dependencies = [
"oneshot",
"pastey",
"portable-atomic",
- "rand 0.10.3",
+ "rand",
"regress",
"rustc-hash 2.1.3",
"ryu-js",
@@ -704,7 +708,7 @@ dependencies = [
"cow-utils",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure",
]
@@ -740,6 +744,30 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
[[package]]
name = "bp256"
version = "0.14.0"
@@ -808,7 +836,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -839,14 +867,13 @@ dependencies = [
[[package]]
name = "calloop"
-version = "0.14.4"
+version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
+checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
- "slab",
"tracing",
]
@@ -868,7 +895,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
- "calloop 0.14.4",
+ "calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
@@ -885,9 +912,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.5.1"
+version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1027,9 +1054,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
-version = "4.6.8"
+version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
dependencies = [
"bytes",
"memchr",
@@ -1162,7 +1189,7 @@ dependencies = [
"dispatch2",
"egui",
"muda",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
@@ -1286,9 +1313,9 @@ dependencies = [
[[package]]
name = "ctutils"
-version = "0.4.2"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
"subtle",
@@ -1384,9 +1411,9 @@ dependencies = [
[[package]]
name = "deranged"
-version = "0.5.8"
+version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "des"
@@ -1434,7 +1461,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -1445,7 +1472,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1524,7 +1551,7 @@ dependencies = [
"elliptic-curve",
"rfc6979",
"signature 3.0.0",
- "spki 0.8.0",
+ "spki 0.8.1",
"zeroize",
]
@@ -1556,7 +1583,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"subtle",
"zeroize",
]
@@ -1580,7 +1607,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
@@ -1650,7 +1677,7 @@ dependencies = [
"bytemuck",
"egui",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"profiling",
@@ -1711,9 +1738,9 @@ dependencies = [
[[package]]
name = "either"
-version = "1.18.0"
+version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "elliptic-curve"
@@ -1828,7 +1855,7 @@ dependencies = [
"ecolor",
"emath",
"epaint_default_fonts",
- "font-types 0.12.5",
+ "font-types 0.12.7",
"harfrust",
"log",
"nohash-hasher",
@@ -1871,9 +1898,9 @@ dependencies = [
[[package]]
name = "equivalent"
-version = "1.0.2"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
@@ -1882,7 +1909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2052,7 +2079,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
@@ -2080,9 +2107,9 @@ dependencies = [
[[package]]
name = "font-types"
-version = "0.12.5"
+version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
+checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
dependencies = [
"bytemuck",
"serde",
@@ -2115,7 +2142,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -2195,7 +2222,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -2365,7 +2392,7 @@ dependencies = [
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2911,9 +2938,9 @@ dependencies = [
[[package]]
name = "inout"
-version = "0.2.2"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f"
dependencies = [
"block-padding",
"hybrid-array",
@@ -3097,9 +3124,9 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libloading"
@@ -3155,9 +3182,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
-version = "1.0.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]]
name = "lock_api"
@@ -3192,10 +3219,10 @@ dependencies = [
"log",
"md-5",
"nom",
- "rand 0.10.3",
+ "rand",
"rangemap",
"rayon",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -3297,13 +3324,13 @@ dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3465,9 +3492,9 @@ dependencies = [
[[package]]
name = "num_threads"
-version = "0.1.7"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
dependencies = [
"libc",
]
@@ -3480,9 +3507,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
[[package]]
name = "objc2"
-version = "0.5.2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b"
dependencies = [
"objc-sys",
"objc2-encode",
@@ -3490,9 +3517,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -3506,7 +3533,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"libc",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-core-data",
"objc2-core-image",
"objc2-foundation 0.2.2",
@@ -3521,7 +3548,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3535,7 +3562,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-core-location",
"objc2-foundation 0.2.2",
]
@@ -3547,7 +3574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -3559,7 +3586,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -3571,7 +3598,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -3582,7 +3609,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -3594,7 +3621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-metal 0.2.2",
]
@@ -3606,7 +3633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-contacts",
"objc2-foundation 0.2.2",
]
@@ -3627,7 +3654,7 @@ dependencies = [
"block2 0.5.1",
"dispatch",
"libc",
- "objc2 0.5.2",
+ "objc2 0.5.3",
]
[[package]]
@@ -3638,7 +3665,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3649,7 +3676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3660,7 +3687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
@@ -3673,7 +3700,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -3685,7 +3712,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
]
@@ -3697,7 +3724,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-metal 0.2.2",
]
@@ -3709,7 +3736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3722,7 +3749,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
dependencies = [
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -3734,7 +3761,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-image",
@@ -3754,7 +3781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -3766,7 +3793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -3778,7 +3805,7 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-core-location",
"objc2-foundation 0.2.2",
]
@@ -3855,7 +3882,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3869,7 +3896,7 @@ dependencies = [
"fiat-crypto",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3883,7 +3910,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -4007,7 +4034,7 @@ dependencies = [
"pdfcraft-xfa",
"png 0.18.1",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"tempfile",
]
@@ -4075,7 +4102,7 @@ dependencies = [
"aes",
"md-5",
"proptest",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
]
@@ -4336,7 +4363,7 @@ dependencies = [
"rustls-cng",
"security-framework",
"sha1 0.11.0",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"sha3",
"thiserror 2.0.21",
]
@@ -4363,7 +4390,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pdfcraft-cos",
@@ -4411,7 +4438,7 @@ dependencies = [
"pdfcraft-fonts",
"png 0.18.1",
"roxmltree",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"thiserror 2.0.21",
]
@@ -4614,12 +4641,12 @@ dependencies = [
[[package]]
name = "pkcs1"
-version = "0.8.0-rc.4"
+version = "0.8.0-rc.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
+checksum = "f485dbafe293ea6ccaa7d82bc591651a796eb8a9aec3df040b057b706073dbc3"
dependencies = [
"der 0.8.2",
- "spki 0.8.0",
+ "spki 0.8.1",
]
[[package]]
@@ -4629,7 +4656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der 0.8.2",
- "spki 0.8.0",
+ "spki 0.8.1",
]
[[package]]
@@ -4731,18 +4758,9 @@ dependencies = [
[[package]]
name = "powerfmt"
-version = "0.2.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "presser"
@@ -4827,16 +4845,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
- "rand 0.9.5",
- "rand_chacha",
+ "rand",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -4901,7 +4920,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
- "rand 0.10.3",
+ "rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
@@ -4925,7 +4944,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4949,16 +4968,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-[[package]]
-name = "rand"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand"
version = "0.10.3"
@@ -4970,31 +4979,12 @@ dependencies = [
"rand_core 0.10.1",
]
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -5012,11 +5002,11 @@ dependencies = [
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
- "rand_core 0.9.5",
+ "rand_core 0.10.1",
]
[[package]]
@@ -5043,7 +5033,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
@@ -5095,7 +5085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
- "font-types 0.12.5",
+ "font-types 0.12.7",
"once_cell",
]
@@ -5106,7 +5096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
dependencies = [
"bytemuck",
- "font-types 0.12.5",
+ "font-types 0.12.7",
"once_cell",
]
@@ -5236,7 +5226,7 @@ dependencies = [
"js-sys",
"libc",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -5462,7 +5452,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5475,7 +5465,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5507,9 +5497,9 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
@@ -5545,7 +5535,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5607,9 +5597,9 @@ dependencies = [
[[package]]
name = "schannel"
-version = "0.1.29"
+version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
@@ -5702,14 +5692,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -5737,14 +5727,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "serde_spanned"
-version = "1.1.1"
+version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
@@ -5806,9 +5796,9 @@ dependencies = [
[[package]]
name = "sha2"
-version = "0.11.0"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
@@ -5964,7 +5954,7 @@ dependencies = [
"const-oid 0.9.6",
"der 0.7.10",
"hex",
- "rand 0.10.3",
+ "rand",
"reqwest",
"rustls-pki-types",
"rustls-webpki",
@@ -6114,9 +6104,9 @@ dependencies = [
[[package]]
name = "smallvec"
-version = "1.16.2"
+version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
dependencies = [
"serde",
]
@@ -6153,7 +6143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
- "calloop 0.14.4",
+ "calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
@@ -6200,7 +6190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6224,9 +6214,9 @@ dependencies = [
[[package]]
name = "spki"
-version = "0.8.0"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
+checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
dependencies = [
"base64ct",
"der 0.8.2",
@@ -6330,9 +6320,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "3.0.6"
+version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -6356,7 +6346,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -6419,10 +6409,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6436,9 +6426,9 @@ dependencies = [
[[package]]
name = "thin-vec"
-version = "0.2.20"
+version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748"
+checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e"
[[package]]
name = "thiserror"
@@ -6477,7 +6467,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -6592,9 +6582,9 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.53.1"
+version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
@@ -6630,9 +6620,9 @@ dependencies = [
[[package]]
name = "toml"
-version = "1.1.6+spec-1.1.0"
+version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
@@ -6645,18 +6635,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
-version = "0.25.15+spec-1.1.0"
+version = "0.25.17+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"toml_datetime",
@@ -6666,18 +6656,18 @@ dependencies = [
[[package]]
name = "toml_parser"
-version = "1.1.3+spec-1.1.0"
+version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
-version = "1.1.2+spec-1.1.0"
+version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tower"
@@ -6791,7 +6781,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6814,15 +6804,15 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
-version = "1.0.26"
+version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-normalization"
-version = "0.1.25"
+version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
@@ -6938,9 +6928,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
-version = "1.26.1"
+version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"js-sys",
"serde_core",
@@ -7111,7 +7101,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"wasm-bindgen-shared",
]
@@ -7288,7 +7278,7 @@ dependencies = [
"jni",
"log",
"ndk-context",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
@@ -7444,7 +7434,7 @@ dependencies = [
"naga",
"naga-types",
"ndk-sys",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -7505,7 +7495,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7626,16 +7616,16 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-targets",
]
[[package]]
name = "windows-sys"
-version = "0.60.2"
+version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -7653,31 +7643,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -7695,96 +7668,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winit"
version = "0.30.13"
@@ -7806,7 +7731,7 @@ dependencies = [
"libc",
"memmap2",
"ndk",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2",
@@ -7866,9 +7791,9 @@ dependencies = [
[[package]]
name = "winresource"
-version = "0.1.31"
+version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
@@ -8031,7 +7956,7 @@ dependencies = [
"png 0.18.1",
"serde",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"toml",
]
@@ -8054,7 +7979,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure",
]
@@ -8126,7 +8051,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -8134,9 +8059,9 @@ dependencies = [
[[package]]
name = "zbus_names"
-version = "4.3.4"
+version = "4.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
dependencies = [
"serde",
"winnow",
@@ -8166,18 +8091,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.59"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
+checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.59"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
+checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
@@ -8201,15 +8126,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure",
]
[[package]]
name = "zeroize"
-version = "1.9.0"
+version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
dependencies = [
"zeroize_derive",
]
@@ -8255,7 +8180,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -8287,9 +8212,9 @@ dependencies = [
[[package]]
name = "zvariant"
-version = "5.15.0"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
dependencies = [
"endi",
"enumflags2",
@@ -8302,14 +8227,14 @@ dependencies = [
[[package]]
name = "zvariant_derive"
-version = "5.15.0"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"zvariant_utils",
]
@@ -8322,6 +8247,6 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
- "syn 3.0.6",
+ "syn 3.0.9",
"winnow",
]
From a02b7198ee032c728794b50e629d8caccb0cfdda Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Sat, 17 Oct 2026 21:42:50 +0530
Subject: [PATCH 025/289] test: implement deterministic mock clipboard to
resolve Windows heap corruption& audit fixes
---
deny.toml | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/deny.toml b/deny.toml
index 2362d56822..b42a16b547 100644
--- a/deny.toml
+++ b/deny.toml
@@ -48,6 +48,11 @@ ignore = [
# winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows
# and is not in the binary.
{ id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" },
+ # zbus_names, zvariant, and zvariant_derive are unmaintained (no vulnerability). Reached via
+ # eframe -> accesskit_unix until upstream migrates to zbus v6.
+ { id = "RUSTSEC-2026-0370", reason = "unmaintained zbus_names dependency via eframe -> accesskit_unix until upstream migrates to zbus v6" },
+ { id = "RUSTSEC-2026-0371", reason = "unmaintained zvariant dependency via eframe -> accesskit_unix until upstream migrates to zbus v6" },
+ { id = "RUSTSEC-2026-0372", reason = "unmaintained zvariant_derive dependency via eframe -> accesskit_unix until upstream migrates to zbus v6" },
]
# egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md
From ca76c52511eadc96b3e9882c4e5c8e436751d46c Mon Sep 17 00:00:00 2001
From: Combiler01
Date: Sat, 17 Oct 2026 22:52:53 +0530
Subject: [PATCH 026/289] Restored some code
---
.github/workflows/ci.yml | 4 +-
apps/pdfcraft/src/main.rs | 2 -
crates/ui-egui/src/clipboard.rs | 38 ------------------
crates/ui-egui/src/content_ui.rs | 69 +++++++++-----------------------
crates/ui-egui/src/create_ui.rs | 22 ++--------
crates/ui-egui/src/image_pick.rs | 2 +-
crates/ui-egui/src/lib.rs | 47 +++-------------------
crates/ui-egui/src/zoom_snap.rs | 13 ++----
crates/ui-egui/tests/editing.rs | 26 ++----------
deny.toml | 5 ---
10 files changed, 38 insertions(+), 190 deletions(-)
delete mode 100644 crates/ui-egui/src/clipboard.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 388b44f032..7eca1256b0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,11 +85,11 @@ jobs:
- name: Test
if: runner.os != 'Linux'
- run: cargo test --workspace
+ run: cargo test --workspace -- --test-threads=1
- name: Test (with craft-fonts)
if: runner.os == 'Linux'
- run: cargo test --workspace
+ run: cargo test --workspace -- --test-threads=1
- name: Layering
if: runner.os == 'Linux'
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 5af9fa3fc0..7a0bb20432 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -368,8 +368,6 @@ fn app_creator<'a>(
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
- app.system_clipboard = true;
- app.clipboard_override = None;
// First start (or settings from before Units existed): the region's unit.
app.units = pdfcraft_ui_egui::units::Unit::system();
#[cfg(target_os = "linux")]
diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs
deleted file mode 100644
index a30304fa66..0000000000
--- a/crates/ui-egui/src/clipboard.rs
+++ /dev/null
@@ -1,38 +0,0 @@
-//! Deterministic in-memory clipboard for headless UI tests and automation.
-//!
-//! Prevents headless tests running in parallel from competing for the native
-//! operating system clipboard, eliminating concurrency races and OS heap corruption.
-
-use std::path::PathBuf;
-
-/// A deterministic, in-memory mock clipboard for tests.
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct MockClipboard {
- pub text: Option,
- pub image: Option<(usize, usize, Vec)>,
- pub files: Option>,
-}
-
-impl MockClipboard {
- /// Creates an empty mock clipboard.
- pub fn new() -> Self {
- Self::default()
- }
-
- /// Creates a mock clipboard containing plain text.
- pub fn with_text(text: impl Into) -> Self {
- Self { text: Some(text.into()), image: None, files: None }
- }
-
- /// Creates a mock clipboard containing an image (width, height, RGBA bytes).
- pub fn with_image(width: usize, height: usize, rgba: Vec) -> Self {
- Self { text: None, image: Some((width, height, rgba)), files: None }
- }
-
- /// Clears all clipboard contents.
- pub fn clear(&mut self) {
- self.text = None;
- self.image = None;
- self.files = None;
- }
-}
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index c6d914d243..61be108256 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -821,19 +821,14 @@ impl crate::PdfCraftApp {
return false;
};
let copied = pixels.and_then(|(w, h, rgba)| {
- if let Some(mock) = &mut self.clipboard_override {
- mock.image = Some((w as usize, h as usize, rgba.clone()));
- mock.text = None;
- } else {
- #[cfg(not(target_arch = "wasm32"))]
- if self.system_clipboard {
- let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) };
- arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
- }
- #[cfg(target_arch = "wasm32")]
- if self.system_clipboard {
- return Err("the web version can't put images on the clipboard yet; use Save Image As".to_string());
- }
+ #[cfg(not(target_arch = "wasm32"))]
+ if self.system_clipboard {
+ let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) };
+ arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
+ }
+ #[cfg(target_arch = "wasm32")]
+ if self.system_clipboard {
+ return Err("the web version can't put images on the clipboard yet; use Save Image As".to_string());
}
self.last_copied_image = Some((w, h, rgba));
Ok((w, h))
@@ -900,25 +895,11 @@ 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 let Some(mock) = &self.clipboard_override {
- if mock.text.as_ref().is_some_and(|t| !t.is_empty()) {
- return false;
- }
- if mock.image.is_some() {
- return true;
- }
- return self.last_copied_image.is_some() || self.last_copied_file.is_some();
- }
#[cfg(not(target_arch = "wasm32"))]
{
if self.system_clipboard
&& let Ok(mut cb) = arboard::Clipboard::new()
{
- if let Ok(text) = cb.get_text()
- && !text.is_empty()
- {
- return false;
- }
return cb.get_image().is_ok();
}
}
@@ -937,33 +918,19 @@ impl crate::PdfCraftApp {
return false;
}
- let (has_system_cb, os_image, system_has_text) = if let Some(mock) = &self.clipboard_override {
- let has_text = mock.text.as_ref().is_some_and(|t| !t.is_empty());
- let img = mock.image.clone();
- (true, img, has_text)
- } else {
- #[cfg(not(target_arch = "wasm32"))]
- if self.system_clipboard {
- if let Ok(mut cb) = arboard::Clipboard::new() {
- let has_text = cb.get_text().map(|t| !t.is_empty()).unwrap_or(false);
- let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec()));
- (true, img_opt, has_text)
- } else {
- (false, None, false)
- }
+ #[cfg(not(target_arch = "wasm32"))]
+ let (has_system_cb, os_image) = if self.system_clipboard {
+ if let Ok(mut cb) = arboard::Clipboard::new() {
+ let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec()));
+ (true, img_opt)
} else {
- (false, None, false)
+ (false, None)
}
- #[cfg(target_arch = "wasm32")]
- (false, None, false)
+ } else {
+ (false, None)
};
-
- if system_has_text {
- self.last_copied_image = None;
- self.last_copied_file = None;
- self.last_copied_image_rect = None;
- return false;
- }
+ #[cfg(target_arch = "wasm32")]
+ 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 {
diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs
index e1ecbbe1f1..fb8f1b2e27 100644
--- a/crates/ui-egui/src/create_ui.rs
+++ b/crates/ui-egui/src/create_ui.rs
@@ -111,24 +111,10 @@ impl PdfCraftApp {
/// Create ▸ Clipboard: a new document from the image (one page, its size) or the text on
/// the clipboard, as Acrobat does.
pub(crate) fn create_from_clipboard(&mut self) {
- let clip = if let Some(mock) = &self.clipboard_override {
- if let Some((w, h, rgba)) = &mock.image {
- Some(Clip::Image { width: *w, height: *h, rgba: rgba.clone() })
- } else {
- mock.text.as_ref().map(|t| Clip::Text(t.clone()))
- }
- } else if self.system_clipboard {
- #[cfg(not(target_arch = "wasm32"))]
- {
- read_clipboard()
- }
- #[cfg(target_arch = "wasm32")]
- {
- None
- }
- } else {
- None
- };
+ #[cfg(not(target_arch = "wasm32"))]
+ let clip = read_clipboard();
+ #[cfg(target_arch = "wasm32")]
+ let clip: Option = None;
match clip {
Some(c) => {
if let Err(e) = self.create_from_clip(c) {
diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs
index 9be0a8efc3..9e117395cd 100644
--- a/crates/ui-egui/src/image_pick.rs
+++ b/crates/ui-egui/src/image_pick.rs
@@ -203,7 +203,7 @@ impl PdfCraftApp {
/// previous copy, and puts that file on the system clipboard.
#[cfg(not(target_arch = "wasm32"))]
fn put_file_on_clipboard(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
- if self.clipboard_override.is_some() || !self.system_clipboard {
+ if !self.system_clipboard {
return Ok(());
}
let dir = clipboard_dir().ok_or("there is no folder for the copied file")?;
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index bc5970aef5..ba8fd6390d 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -44,8 +44,6 @@ mod combine_ui;
pub mod docking;
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
mod camera_ui;
-pub mod clipboard;
-pub use clipboard::MockClipboard;
mod commands;
mod comment_props;
pub mod comments;
@@ -734,9 +732,6 @@ pub struct PdfCraftApp {
pub last_copied_file: Option<(String, Vec)>,
pub last_copied_image_rect: Option<[f64; 4]>,
pub system_clipboard: bool,
- /// Deterministic in-memory clipboard for headless UI tests and automation.
- /// When `Some`, clipboard operations use this mock instead of the OS clipboard.
- pub clipboard_override: Option,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec)>,
/// Duplicate Field: which field and onto which pages.
@@ -1037,8 +1032,7 @@ impl PdfCraftApp {
last_copied_image: None,
last_copied_file: None,
last_copied_image_rect: None,
- system_clipboard: false,
- clipboard_override: Some(MockClipboard::default()),
+ system_clipboard: true,
attach_override: None,
duplicate_draft: None,
form_preview: false,
@@ -2514,31 +2508,6 @@ impl PdfCraftApp {
}
}
- /// Injects a deterministic mock clipboard for headless tests.
- pub fn set_mock_clipboard(&mut self, mock: MockClipboard) {
- self.clipboard_override = Some(mock);
- }
-
- /// Sets plain text on the mock clipboard (creating one if absent).
- pub fn set_mock_clipboard_text(&mut self, text: impl Into) {
- if let Some(mock) = &mut self.clipboard_override {
- mock.text = Some(text.into());
- mock.image = None;
- } else {
- self.clipboard_override = Some(MockClipboard::with_text(text));
- }
- }
-
- /// Sets an image on the mock clipboard (creating one if absent).
- pub fn set_mock_clipboard_image(&mut self, width: usize, height: usize, rgba: Vec) {
- if let Some(mock) = &mut self.clipboard_override {
- mock.image = Some((width, height, rgba));
- mock.text = None;
- } else {
- self.clipboard_override = Some(MockClipboard::with_image(width, height, rgba));
- }
- }
-
/// `true` while any open document still waits for page renders (used by headless capture).
pub fn render_pending(&self) -> bool {
self.views.iter().any(|v| v.render_pending())
@@ -2981,18 +2950,14 @@ impl PdfCraftApp {
self.cut_page_image(sel.page, sel.index);
}
if paste {
- let system_has_text = if let Some(mock) = &self.clipboard_override {
- mock.text.as_ref().is_some_and(|t| !t.is_empty())
+ #[cfg(not(target_arch = "wasm32"))]
+ let system_has_text = if self.system_clipboard {
+ arboard::Clipboard::new().and_then(|mut cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false)
} else {
- #[cfg(not(target_arch = "wasm32"))]
- if self.system_clipboard {
- arboard::Clipboard::new().and_then(|mut cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false)
- } else {
- false
- }
- #[cfg(target_arch = "wasm32")]
false
};
+ #[cfg(target_arch = "wasm32")]
+ let system_has_text = false;
if paste_event_text.is_some() || system_has_text {
self.last_copied_image = None;
diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs
index d74627e877..eb2cc86727 100644
--- a/crates/ui-egui/src/zoom_snap.rs
+++ b/crates/ui-egui/src/zoom_snap.rs
@@ -138,15 +138,10 @@ impl PdfCraftApp {
return Err(e);
}
let (w, h) = (out.width, out.height);
- if let Some(mock) = &mut self.clipboard_override {
- mock.image = Some((w as usize, h as usize, out.rgba.to_vec()));
- mock.text = None;
- } else {
- #[cfg(not(target_arch = "wasm32"))]
- if self.system_clipboard {
- let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
- arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
- }
+ #[cfg(not(target_arch = "wasm32"))]
+ if self.system_clipboard {
+ let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
+ arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
}
self.last_snapshot = Some((w, h, out.rgba));
Ok((w, h))
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index 02e40bdad3..a9c3dca38a 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -44,7 +44,6 @@ fn harness(pages: usize, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harn
let mut h = builder.build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
- app.clipboard_override = Some(pdfcraft_ui_egui::MockClipboard::default());
app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens");
setup(&mut app);
app
@@ -4979,13 +4978,11 @@ fn stale_clipboard_plain_text_does_not_paste_image() {
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;
- h.state_mut().clipboard_override = None;
- // Set actual system clipboard to plain text, asserting success explicitly
+ // Set actual system clipboard to plain text
#[cfg(not(target_arch = "wasm32"))]
- {
- let mut cb = arboard::Clipboard::new().expect("acquire OS clipboard");
- cb.set_text("just plain text").expect("set OS clipboard text");
+ if let Ok(mut cb) = arboard::Clipboard::new() {
+ cb.set_text("just plain text").ok();
}
// A paste event containing plain text clears internal image cache and does not paste image
@@ -5002,23 +4999,6 @@ fn stale_clipboard_plain_text_does_not_paste_image() {
assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page");
}
-#[test]
-fn deterministic_mock_clipboard_plain_text_does_not_paste_image() {
- let mut h = harness(1, |_| {});
- h.run_steps(4);
- 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]));
-
- // Inject plain text into the mock clipboard
- h.state_mut().set_mock_clipboard_text("just plain text");
-
- assert!(!h.state().can_paste_image(), "cannot paste image when mock clipboard has text");
- assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image");
-
- let doc = h.state().session.get(h.state().views[0].id).unwrap();
- assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page");
-}
-
#[test]
fn rotated_image_selection_and_crop() {
let mut png = Vec::new();
diff --git a/deny.toml b/deny.toml
index b42a16b547..2362d56822 100644
--- a/deny.toml
+++ b/deny.toml
@@ -48,11 +48,6 @@ ignore = [
# winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows
# and is not in the binary.
{ id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" },
- # zbus_names, zvariant, and zvariant_derive are unmaintained (no vulnerability). Reached via
- # eframe -> accesskit_unix until upstream migrates to zbus v6.
- { id = "RUSTSEC-2026-0370", reason = "unmaintained zbus_names dependency via eframe -> accesskit_unix until upstream migrates to zbus v6" },
- { id = "RUSTSEC-2026-0371", reason = "unmaintained zvariant dependency via eframe -> accesskit_unix until upstream migrates to zbus v6" },
- { id = "RUSTSEC-2026-0372", reason = "unmaintained zvariant_derive dependency via eframe -> accesskit_unix until upstream migrates to zbus v6" },
]
# egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md
From 558a4c123036c3a4b25259929082b14cff098e53 Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Sat, 17 Oct 2026 10:08:17 -0600
Subject: [PATCH 027/289] Read the system clipboard once per right-click, and
one thread at a time
The canvas asked can_paste_image() on every frame, which opens the OS
clipboard and decodes any image on it. With UI tests running in parallel,
many threads opened the Windows clipboard at once and the editing test
binary died with STATUS_HEAP_CORRUPTION.
- Ask whether there's an image to paste when a right-click opens a menu,
not on every frame. Pasting still reads the clipboard at paste time.
- Route every arboard call through os_clipboard::with, which holds a
process-wide lock across open, use and close.
- stale_clipboard_plain_text_does_not_paste_image no longer writes the
real clipboard; it gets its text from the paste event.
- Run CI tests in parallel again (revert --test-threads=1).
---
.github/workflows/ci.yml | 4 ++--
crates/ui-egui/src/canvas.rs | 11 ++++++++++-
crates/ui-egui/src/content_ui.rs | 16 ++++++----------
crates/ui-egui/src/create_ui.rs | 13 ++++++++-----
crates/ui-egui/src/image_pick.rs | 2 +-
crates/ui-egui/src/lib.rs | 9 ++++-----
crates/ui-egui/src/os_clipboard.rs | 18 ++++++++++++++++++
crates/ui-egui/src/zoom_snap.rs | 2 +-
crates/ui-egui/tests/editing.rs | 14 +++++---------
9 files changed, 55 insertions(+), 34 deletions(-)
create mode 100644 crates/ui-egui/src/os_clipboard.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7eca1256b0..388b44f032 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,11 +85,11 @@ jobs:
- name: Test
if: runner.os != 'Linux'
- run: cargo test --workspace -- --test-threads=1
+ run: cargo test --workspace
- name: Test (with craft-fonts)
if: runner.os == 'Linux'
- run: cargo test --workspace -- --test-threads=1
+ run: cargo test --workspace
- name: Layering
if: runner.os == 'Linux'
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index cc91fef9e9..2b3366e9c2 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2840,7 +2840,16 @@ 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();
+ // Asking means opening the system clipboard (and decoding any image on it), so it's asked
+ // once when a right-click opens a menu, not on every frame.
+ let can_paste_key = egui::Id::new("can-paste-image");
+ let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) {
+ let can = app.can_paste_image();
+ ui.data_mut(|d| d.insert_temp(can_paste_key, can));
+ can
+ } else {
+ ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false)
+ };
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())));
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 61be108256..d0c3721eb2 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -824,7 +824,7 @@ impl crate::PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
if self.system_clipboard {
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) };
- arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
+ crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?;
}
#[cfg(target_arch = "wasm32")]
if self.system_clipboard {
@@ -897,10 +897,8 @@ impl crate::PdfCraftApp {
pub fn can_paste_image(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
- if self.system_clipboard
- && let Ok(mut cb) = arboard::Clipboard::new()
- {
- return cb.get_image().is_ok();
+ if self.system_clipboard {
+ return crate::os_clipboard::with(|cb| cb.get_image().map(|_| ())).is_ok();
}
}
self.last_copied_image.is_some() || self.last_copied_file.is_some()
@@ -920,11 +918,9 @@ impl crate::PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
let (has_system_cb, os_image) = if self.system_clipboard {
- if let Ok(mut cb) = arboard::Clipboard::new() {
- let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec()));
- (true, img_opt)
- } else {
- (false, None)
+ match crate::os_clipboard::with(|cb| Ok(cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.into_owned())))) {
+ Ok(img_opt) => (true, img_opt),
+ Err(_) => (false, None),
}
} else {
(false, None)
diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs
index fb8f1b2e27..bf12378206 100644
--- a/crates/ui-egui/src/create_ui.rs
+++ b/crates/ui-egui/src/create_ui.rs
@@ -59,11 +59,14 @@ pub enum Clip {
/// An image on the system clipboard, or else its text.
#[cfg(not(target_arch = "wasm32"))]
fn read_clipboard() -> Option {
- let mut cb = arboard::Clipboard::new().ok()?;
- if let Ok(img) = cb.get_image() {
- return Some(Clip::Image { width: img.width, height: img.height, rgba: img.bytes.into_owned() });
- }
- cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text)
+ crate::os_clipboard::with(|cb| {
+ if let Ok(img) = cb.get_image() {
+ return Ok(Some(Clip::Image { width: img.width, height: img.height, rgba: img.bytes.into_owned() }));
+ }
+ Ok(cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text))
+ })
+ .ok()
+ .flatten()
}
pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result