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/270] 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/270] 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/270] 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 fc14719dff434e692123cb19b8c94e07a2d314cc Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Tue, 13 Oct 2026 19:18:41 +0200
Subject: [PATCH 004/270] feat(forms): edit checkbox and radio export values
(#231)
---
ROADMAP.md | 2 ++
crates/automation/README.md | 12 ++++++++
crates/automation/src/forms.rs | 17 +++++++++-
crates/automation/src/tools.rs | 4 ++-
crates/automation/tests/form_bulk.rs | 23 ++++++++++++++
crates/forms/README.md | 13 ++++++++
crates/forms/src/author.rs | 46 ++++++++++++++++++++++++++++
crates/forms/src/lib.rs | 9 ++++++
crates/forms/src/tests.rs | 39 +++++++++++++++++++++++
crates/ui-egui/src/prepare.rs | 8 ++++-
crates/ui-egui/tests/prepare.rs | 33 ++++++++++++++++++++
crates/xfdf/src/lib.rs | 13 +++++---
crates/xfdf/src/tests.rs | 33 ++++++++++++++++++++
parity/acrobat-features.toml | 4 +--
14 files changed, 246 insertions(+), 10 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index a035c9bf35..3013de8b1a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,5 +1,7 @@
# PdfCraft roadmap
+> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6 #231: edit checkbox/radio export values through individual field properties and automation, preserving selection and appearance; headless regression coverage, desktop visual verification pending; estimates unchanged)
+
**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)
diff --git a/crates/automation/README.md b/crates/automation/README.md
index f2e4ec9282..f20ad35ea1 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -184,3 +184,15 @@ unapplied with a warning.
`doc_batch` runs conversion, OCR and compression over files and optionally recursive
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.
+
+## Editing button export values
+
+Use `form_set_props` with `field`, `export_value` and optional `widget`
+(1-based, default 1) to change a checkbox or radio button export value.
+`form_fields` returns `export_values` in widget order. The selected/default
+appearance states are preserved; the operation is undoable and survives saving.
+Widgets sharing an appearance state keep one export value. Bulk edits are not
+supported for this widget-specific property. Empty values, `Off`, conflicting
+choices and values over 4096 UTF-8 bytes return an error.
+
+Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs
index 7c60425a8c..6089d5a36a 100644
--- a/crates/automation/src/forms.rs
+++ b/crates/automation/src/forms.rs
@@ -232,6 +232,9 @@ impl Automation {
if !f.actions.unsupported.is_empty() {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
+ if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
+ o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::>()));
+ }
match f.kind {
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>()));
@@ -327,6 +330,9 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result {
+ if a.get("widget").is_some() && a.get("export_value").is_none() {
+ return Err(bad("widget requires export_value"));
+ }
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
if single.is_some() == bulk {
@@ -344,7 +350,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
- for key in ["name", "rect", "rotation"] {
+ for key in ["name", "rect", "rotation", "export_value", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -396,6 +402,15 @@ impl Automation {
};
Ok(FieldProps {
rect,
+ export_value: a
+ .opt_str("export_value")?
+ .map(|value| -> Result<_> {
+ let index = a.opt_int("widget")?.unwrap_or(1);
+ let index =
+ usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
+ Ok((index, value.to_owned()))
+ })
+ .transpose()?,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
read_only: a.opt_bool("read_only")?,
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index cfdeb1c16f..be85f00077 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -364,7 +364,7 @@ pub fn tools() -> Vec {
t(
"form_set_props",
"Field properties",
- "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
+ "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and export_value require a single field. export_value edits a checkbox or radio widget (widget: 1-based, default 1), preserving its selected/default state; widgets sharing that state keep the same export value. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
)
.with({
let mut input = schema(
@@ -373,6 +373,8 @@ pub fn tools() -> Vec {
"field": { "type": "string", "description": "The field's current name." },
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
+ "export_value": { "type": "string", "minLength": 1, "description": "Checkbox/radio export value; not Off; at most 4096 UTF-8 bytes." },
+ "widget": { "type": "integer", "minimum": 1, "description": "1-based widget for export_value (default 1)." },
"tooltip": { "type": "string" },
"read_only": { "type": "boolean" },
"required": { "type": "boolean" },
diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/form_bulk.rs
index 8b8904c974..4296cbc891 100644
--- a/crates/automation/tests/form_bulk.rs
+++ b/crates/automation/tests/form_bulk.rs
@@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}
+
+#[test]
+fn button_export_edit_is_one_undo_step_and_survives_save() {
+ let (mut a, id, dir) = setup("button-export");
+ tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]}));
+ tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}}));
+ let before = bytes(&a, id);
+ call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1}));
+ let f = tool(&mut a, "form_fields", json!({"doc": id}));
+ let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
+ assert_eq!(check["export_values"], json!(["Accepted"]));
+ assert_eq!(check["value"], true);
+ tool(&mut a, "edit_undo", json!({"doc": id}));
+ assert_eq!(bytes(&a, id), before);
+ tool(&mut a, "edit_redo", json!({"doc": id}));
+ tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
+ let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
+ let f = tool(&mut a, "form_fields", json!({"doc": reopened}));
+ let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
+ assert_eq!(check["export_values"], json!(["Accepted"]));
+ assert_eq!(check["value"], true);
+ std::fs::remove_dir_all(dir).unwrap();
+}
diff --git a/crates/forms/README.md b/crates/forms/README.md
index 3280cc2431..bc8e19fc95 100644
--- a/crates/forms/README.md
+++ b/crates/forms/README.md
@@ -57,6 +57,19 @@ delete_field(&mut doc, "email")?;
buttons joining a group become kid widgets with their own export state. Renaming changes only
the last part of a dotted name.
+## Button export values
+
+`FieldProps::export_value = Some((widget_index, value))` edits a checkbox or radio
+button's export value. The API index is zero-based. It writes a complete `/Opt`
+mapping, preserving appearance names, selected states and reset defaults; an
+export-only change leaves custom appearance streams intact. Widgets sharing an
+appearance state keep the same export value. Conflicting shared-state mappings,
+ambiguous choices, invalid widget indices, empty values, `Off`, and values over
+4096 UTF-8 bytes are refused.
+
+`Field::export_values` resolves button values for data exchange. An unchecked
+button still exports as `Off` in the data formats.
+
## Not yet
Rich-text values and exact appearance metrics/layout remain incomplete. JavaScript
diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs
index 6d49be1001..a8c99cb483 100644
--- a/crates/forms/src/author.rs
+++ b/crates/forms/src/author.rs
@@ -68,6 +68,9 @@ pub struct FieldProps {
pub max_len: Option>,
/// Choice options (display texts; export values equal them).
pub options: Option>,
+ /// Change one button widget's export value without changing its appearance state.
+ /// Widgets sharing that state are kept in sync. The index is zero-based.
+ pub export_value: Option<(usize, String)>,
/// Font size (0 = auto).
pub font_size: Option,
/// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in
@@ -845,6 +848,36 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out)
}
+/// Build a complete /Opt mapping before modifying the document. Appearance names,
+/// /V and /DV stay untouched, so an export-value edit does not change selection.
+fn button_export_options(f: &Field, index: usize, value: &str) -> Result, FormError> {
+ if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
+ return invalid("only check boxes and radio buttons have an export value");
+ }
+ if value.is_empty() || value == "Off" || value.len() > 4096 {
+ return invalid("export value must be non-empty, at most 4096 bytes, and not Off");
+ }
+ let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?;
+ let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
+ if !f.options.is_empty() && f.options.len() != f.widgets.len() {
+ return invalid("button export options do not match its widgets");
+ }
+ let previous = f.export_of(index);
+ let mut out = Vec::with_capacity(f.widgets.len());
+ for (i, w) in f.widgets.iter().enumerate() {
+ let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
+ let same_state = w.on_state.as_deref() == Some(state);
+ if !same_state && (export == value || w.on_state.as_deref() == Some(value)) {
+ return invalid("export value conflicts with another button choice");
+ }
+ if same_state && Some(export) != previous {
+ return invalid("widgets with the same appearance state have conflicting export values");
+ }
+ out.push(Object::String(PdfString::text(if same_state { value } else { export })));
+ }
+ Ok(out)
+}
+
/// Change a field's properties (General and Options tabs) and redraw it.
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result {
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
@@ -858,6 +891,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None;
@@ -879,6 +916,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Vec {
+ if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) {
+ self.value.iter().map(|v| self.export_for_state(v).to_string()).collect()
+ } else {
+ self.value.clone()
+ }
+ }
+
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
index 0256bfafb9..e3fa748651 100644
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -1943,3 +1943,42 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() {
assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
assert!(filled.rules.is_empty());
}
+
+#[test]
+fn editing_button_export_values_preserves_selection_and_appearance() {
+ let mut doc = fixture();
+ set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap();
+ let before = field(&fields(&doc), "size").clone();
+ let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect();
+ set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap();
+ let after = field(&fields(&doc), "size").clone();
+ assert_eq!(after.value, before.value);
+ assert_eq!(after.default, before.default);
+ assert_eq!(after.widgets, before.widgets);
+ assert_eq!(after.export_of(0), Some("Small å"));
+ assert_eq!(after.export_of(1), before.export_of(1));
+ assert_eq!(after.export_values(), vec!["Small å"]);
+ assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::>());
+ let mut saved = reopen(&doc);
+ assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å"));
+ set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap();
+ reset(&mut saved, None).unwrap();
+ assert_eq!(field(&fields(&saved), "size").value, before.default);
+
+ set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
+ set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap();
+ let check = field(&fields(&doc), "agree").clone();
+ assert_eq!(check.value, vec!["Yes"]);
+ assert_eq!(check.export_values(), vec!["Accepted"]);
+ assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted"));
+}
+
+#[test]
+fn invalid_button_export_edits_leave_document_unchanged() {
+ let mut doc = fixture();
+ let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] {
+ assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err());
+ assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
+ }
+}
diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs
index 37747f14c7..762d090254 100644
--- a/crates/ui-egui/src/prepare.rs
+++ b/crates/ui-egui/src/prepare.rs
@@ -758,6 +758,7 @@ pub struct FieldDraft {
pub default: String,
/// Check boxes and radio buttons: this widget's "on" state (export value).
pub on_state: String,
+ pub export_value: String,
/// 0 = auto.
pub font_size: f64,
/// Left, bottom, width, height in points.
@@ -824,6 +825,7 @@ impl FieldDraft {
quadding: f.quadding,
default: f.default.first().cloned().unwrap_or_default(),
on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(),
+ export_value: f.export_of(widget).unwrap_or_default().to_string(),
font_size: da_size(&f.da),
position: [r[0], r[1], r[2] - r[0], r[3] - r[1]],
rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0),
@@ -877,6 +879,7 @@ impl FieldDraft {
multiline: ch(self.multiline, o.multiline),
max_len: ((self.limit, self.max_len) != (o.limit, o.max_len)).then_some((self.limit && self.max_len > 0).then_some(self.max_len)),
options: (self.options != o.options).then(|| self.options.clone()),
+ export_value: (self.export_value != o.export_value).then(|| (self.widget, self.export_value.clone())),
font_size: ((self.font_size - o.font_size).abs() > 1e-6).then_some(self.font_size),
rect: (self.position != o.position).then(|| {
let [x, y, w, h] = self.position;
@@ -1123,7 +1126,10 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
FormFieldKind::CheckBox | FormFieldKind::Radio => {
use pdfcraft_engine::field_flags as ff;
let on = d.on_state.clone();
- ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", &on)]));
+ ui.horizontal(|ui| {
+ let label = ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", "")]));
+ ui.text_edit_singleline(&mut d.export_value).labelled_by(label.id);
+ });
let mut checked = !d.default.is_empty() && d.default == on;
let label =
if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") };
diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs
index b23f1a9f8f..81359d8bc2 100644
--- a/crates/ui-egui/tests/prepare.rs
+++ b/crates/ui-egui/tests/prepare.rs
@@ -497,3 +497,36 @@ fn actions_tab_adds_and_removes_actions() {
]
);
}
+
+#[test]
+fn button_export_properties_apply_cancel_and_undo() {
+ let mut h = harness();
+ h.state_mut().execute("form.prepare");
+ h.run_steps(2);
+ h.state_mut().open_field_props("agree", 0);
+ h.run_steps(2);
+ h.get_by_label("Options").click();
+ h.run_steps(2);
+ let original = h.state().field_props.as_ref().unwrap().export_value.clone();
+ h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into();
+ h.get_by_label("Cancel").click();
+ h.run_steps(2);
+ h.state_mut().open_field_props("agree", 0);
+ h.run_steps(2);
+ assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original);
+ h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into();
+ h.get_by_label("OK").click();
+ h.run_steps(3);
+ {
+ let s = h.state();
+ let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap();
+ assert_eq!(f.export_of(0), Some("Accepted"));
+ }
+ h.state_mut().execute("edit.undo");
+ h.run_steps(3);
+ {
+ let s = h.state();
+ let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap();
+ assert_eq!(f.export_of(0), Some(original.as_str()));
+ }
+}
diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs
index d9287101ba..9b1b439caf 100644
--- a/crates/xfdf/src/lib.rs
+++ b/crates/xfdf/src/lib.rs
@@ -420,7 +420,7 @@ fn xfdf_fields(doc: &Document, out: &mut String) {
}
node.values = match f.kind {
FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()],
- _ => f.value.clone(),
+ _ => f.export_values(),
};
}
fn write_node(name: &str, node: &Node, out: &mut String) {
@@ -480,7 +480,7 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V
body.extend_from_slice(b" /V ");
match f.kind {
FieldKind::CheckBox | FieldKind::Radio => {
- let v = f.value.first().cloned().unwrap_or_else(|| "Off".into());
+ let v = f.export_values().first().cloned().unwrap_or_else(|| "Off".into());
pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body);
}
FieldKind::List if f.value.len() > 1 => {
@@ -575,7 +575,7 @@ pub fn export_data(doc: &Document, format: Format) -> String {
.map(|f| {
let v = match f.kind {
FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => "Off".to_string(),
- _ => f.value.join(", "),
+ _ => f.export_values().join(", "),
};
(f.name, v)
})
@@ -984,8 +984,11 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError
.into_iter()
.filter(|f| !matches!(f.kind, FieldKind::PushButton | FieldKind::Signature))
.map(|f| {
- let v =
- if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { vec!["Off".to_string()] } else { f.value };
+ let v = if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() {
+ vec!["Off".to_string()]
+ } else {
+ f.export_values()
+ };
(f.name, v)
})
.collect());
diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs
index be747d77e1..d61273f1b5 100644
--- a/crates/xfdf/src/tests.rs
+++ b/crates/xfdf/src/tests.rs
@@ -887,3 +887,36 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() {
assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}");
}
}
+
+#[test]
+fn edited_button_export_values_round_trip_in_all_data_formats() {
+ use pdfcraft_forms::{FieldProps, set_props};
+ let mut doc = blank();
+ add_field(&mut doc, 0, [80.0, 650.0, 94.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "S".into() }, None).unwrap();
+ add_field(&mut doc, 0, [100.0, 650.0, 114.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "L".into() }, None).unwrap();
+ set_props(&mut doc, "Agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap();
+ set_props(&mut doc, "Size", &FieldProps { export_value: Some((0, "Small".into())), ..Default::default() }).unwrap();
+ set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap();
+ set_value(&mut doc, "Size", &FieldValue::Radio(Some("Small".into()))).unwrap();
+ let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ let mut exports = vec![export_xfdf(&doc, false, true, "").into_bytes(), export_fdf(&doc, false, true, "")];
+ exports.extend([Format::Xml, Format::Csv, Format::Txt].map(|f| export_data(&doc, f).into_bytes()));
+ for (index, bytes) in exports.into_iter().enumerate() {
+ if index < 2 {
+ let values = data_values(&bytes).unwrap();
+ assert_eq!(values.iter().find(|(n, _)| n == "Agree").unwrap().1, vec!["Accepted"]);
+ assert_eq!(values.iter().find(|(n, _)| n == "Size").unwrap().1, vec!["Small"]);
+ } else {
+ // data_values reads XFDF/FDF/PDF; import also handles XML/CSV/TXT.
+ let text = std::str::from_utf8(&bytes).unwrap();
+ assert!(text.contains("Accepted") && text.contains("Small"), "{text}");
+ }
+ let mut target = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ set_value(&mut target, "Agree", &FieldValue::Check(false)).unwrap();
+ set_value(&mut target, "Size", &FieldValue::Radio(Some("L".into()))).unwrap();
+ import(&mut target, &bytes).unwrap();
+ let fs = fields(&target);
+ assert_eq!(fs.iter().find(|f| f.name == "Agree").unwrap().export_values(), vec!["Accepted"]);
+ assert_eq!(fs.iter().find(|f| f.name == "Size").unwrap().export_values(), vec!["Small"]);
+ }
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 8f4194b932..f5189bc33e 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -4896,8 +4896,8 @@ 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"
+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", "crates/forms/src/tests.rs::editing_button_export_values_preserves_selection_and_appearance", "crates/forms/src/tests.rs::invalid_button_export_edits_leave_document_unchanged", "crates/xfdf/src/tests.rs::edited_button_export_values_round_trip_in_all_data_formats", "crates/automation/tests/form_bulk.rs::button_export_edit_is_one_undo_step_and_survives_save", "crates/ui-egui/tests/prepare.rs::button_export_properties_apply_cancel_and_undo"]
+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, individual widget export value via /Opt without changing selection or appearance; XFDF/FDF/XML/CSV/TXT exports use the mapped values. Export editing has headless Apply/Cancel/Undo and save/reopen coverage; desktop typing/appearance has not been visually verified. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
[[feature]]
id = "form.props-actions"
From 0e8d4d70fabbdc4f030af86f801604d1db5766a6 Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Tue, 13 Oct 2026 19:31:14 +0200
Subject: [PATCH 005/270] test(forms): type export values through the
properties widget
---
crates/ui-egui/tests/prepare.rs | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs
index 81359d8bc2..896eeff196 100644
--- a/crates/ui-egui/tests/prepare.rs
+++ b/crates/ui-egui/tests/prepare.rs
@@ -500,6 +500,14 @@ fn actions_tab_adds_and_removes_actions() {
#[test]
fn button_export_properties_apply_cancel_and_undo() {
+ fn retype(h: &mut Harness<'static, PdfCraftApp>, value: &str) {
+ use egui::accesskit::Role;
+ h.get_by_role_and_label(Role::TextInput, "Export Value: ").click();
+ h.run_steps(1);
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
+ h.get_by_role_and_label(Role::TextInput, "Export Value: ").type_text(value);
+ h.run_steps(2);
+ }
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(2);
@@ -508,13 +516,15 @@ fn button_export_properties_apply_cancel_and_undo() {
h.get_by_label("Options").click();
h.run_steps(2);
let original = h.state().field_props.as_ref().unwrap().export_value.clone();
- h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into();
+ retype(&mut h, "Accepted");
h.get_by_label("Cancel").click();
h.run_steps(2);
h.state_mut().open_field_props("agree", 0);
h.run_steps(2);
assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original);
- h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into();
+ h.get_by_label("Options").click();
+ h.run_steps(2);
+ retype(&mut h, "Accepted");
h.get_by_label("OK").click();
h.run_steps(3);
{
From 0807cb05c3ce280a41decd78f0829b368f7341f7 Mon Sep 17 00:00:00 2001
From: JM
Date: Tue, 13 Oct 2026 17:35:45 +0000
Subject: [PATCH 006/270] 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 e2be8db7fbde73c0d10ba896b5dc19561f659bb8 Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Wed, 14 Oct 2026 13:33:19 +0200
Subject: [PATCH 007/270] test(ui): stabilize Reduce metadata and isolate
output directories
---
crates/ui-egui/tests/create.rs | 35 ++++++++++++++++++++++++++++------
1 file changed, 29 insertions(+), 6 deletions(-)
diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs
index bfbba2e131..4537ab1fa6 100644
--- a/crates/ui-egui/tests/create.rs
+++ b/crates/ui-egui/tests/create.rs
@@ -69,12 +69,37 @@ fn opening_images_and_text_converts_them_to_new_pdfs() {
assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err());
}
+// Use a directory claimed exclusively by this run, including after process-ID reuse.
+struct ReduceDir(std::path::PathBuf);
+impl ReduceDir {
+ fn new() -> Self {
+ static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+ for _ in 0..128 {
+ let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let path = std::env::temp_dir().join(format!("pdfcraft-reduce-{}-{n}", std::process::id()));
+ match std::fs::create_dir(&path) {
+ Ok(()) => return Self(path),
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(error) => panic!("creating Reduce test directory: {error}"),
+ }
+ }
+ panic!("no unused Reduce test directory after 128 attempts");
+ }
+}
+impl Drop for ReduceDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+}
+
/// Open `name` and run Reduce File Size on it, saving to `out` without a dialog.
fn reduce(name: &'static str, bytes: Vec, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> {
let out2 = out.to_path_buf();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
+ // Keep creation and optimization metadata identical across wall-clock seconds.
+ app.session = std::mem::take(&mut app.session).with_clock(|| 1_700_000_000);
app.open_bytes(name, None, bytes).unwrap();
app.save_override = Some(out2.to_string_lossy().into_owned());
app
@@ -105,9 +130,8 @@ fn reduce_file_size_writes_a_compact_copy() {
enc.set_depth(png::BitDepth::Eight);
enc.write_header().unwrap().write_image_data(&px).unwrap();
}
- let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
- let out = dir.join("reduced.pdf");
+ let dir = ReduceDir::new();
+ let out = dir.0.join("reduced.pdf");
let h = reduce("photo.png", photo, &out);
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
@@ -119,9 +143,8 @@ fn reduce_file_size_writes_a_compact_copy() {
#[test]
fn reduce_file_size_saves_nothing_when_the_file_is_already_small() {
use egui_kittest::kittest::Queryable;
- let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-small-{}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
- let out = dir.join("reduced.pdf");
+ let dir = ReduceDir::new();
+ let out = dir.0.join("reduced.pdf");
// A new text PDF is already compact: a copy would be no smaller (#490).
let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out);
assert!(!out.exists(), "no copy was written");
From 4e952e62e23e0381c68c2b0f616f4698d5f0f646 Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:40:22 +0200
Subject: [PATCH 008/270] chore: reserve Pages sidebar select-all fix (#2052)
From 971f6ea2b458a4627aab8b4dcf45ba42f00ef18a Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Wed, 14 Oct 2026 15:14:35 +0200
Subject: [PATCH 009/270] Fix keyboard-owned Pages sidebar select all (#2052)
---
ROADMAP.md | 5 +++-
crates/ui-egui/src/canvas.rs | 15 ++++++++---
crates/ui-egui/src/lib.rs | 8 ++++--
crates/ui-egui/src/panels.rs | 15 ++++++++---
crates/ui-egui/tests/view.rs | 52 ++++++++++++++++++++++++++++++++++++
docs/roadmap.md | 5 ++--
parity/acrobat-features.toml | 4 +--
7 files changed, 89 insertions(+), 15 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index b4ab4ea8a6..593387f85a 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M4/#2052 candidate: keyboard-owned Pages sidebar select-all; 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 (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)
@@ -149,6 +149,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **2026-10-14 (M4/#2052 candidate):** Ctrl/Cmd+A selects all thumbnails while the Pages sidebar owns the keyboard, preserves the current page, and keeps viewer/text-field shortcuts and document selection separate. Escape and existing permission checks are retained. Focus regression and scoped UI validation pass; estimates unchanged.
+
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates 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.
@@ -427,6 +429,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | M4/#2052 candidate: Pages sidebar select-all, focus ownership, Escape and document isolation validated; estimates unchanged. |
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged |
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 9dc2c98dfa..1a7bc20a27 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -433,6 +433,8 @@ pub struct DocView {
pub org_drag: Option>,
/// Pages panel: the pages being dragged to a new place.
pub panel_drag: Option>,
+ /// Transient keyboard owner for this document's Pages sidebar.
+ pub(crate) pages_focus_id: Option,
/// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one.
pub marquee: Option<(usize, Pos2)>,
pub marquee_done: Option,
@@ -672,6 +674,7 @@ impl DocView {
sign: Default::default(),
org_drag: None,
panel_drag: None,
+ pages_focus_id: None,
marquee: None,
marquee_done: None,
fill_text: None,
@@ -1137,10 +1140,7 @@ impl DocView {
/// Select all: every page in Organize, or every word on the current page.
pub fn select_all(&mut self) -> bool {
if self.organize {
- self.selected = (0..self.page_count).collect();
- // Keep the current page as the anchor for the next Shift-click.
- self.select_anchor = (self.page_count > 0).then_some(self.current);
- return !self.selected.is_empty();
+ return self.select_all_pages();
}
let page = self.current;
let Some(t) = self.texts.get(&page) else { return false };
@@ -1151,6 +1151,13 @@ impl DocView {
true
}
+ /// Select every page without navigating; shared by Organize and the Pages sidebar.
+ pub fn select_all_pages(&mut self) -> bool {
+ self.selected = (0..self.page_count).collect();
+ self.select_anchor = (self.page_count > 0).then_some(self.current);
+ !self.selected.is_empty()
+ }
+
/// Rotate the view 90° clockwise or counter-clockwise (View ▸ Rotate View, ⇧⌘+ / ⇧⌘−).
pub fn rotate_view(&mut self, clockwise: bool) {
self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360;
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index f800ad6a00..cad36ad3e1 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2650,12 +2650,16 @@ impl PdfCraftApp {
if let Some(i) = self.active {
// Select all belongs to the document or page grid, unless a text field or
// overlay owns the keyboard. Other canvas shortcuts keep their own handling.
+ let pages_owns_keyboard =
+ self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id)));
if self.dialog.is_none()
&& !self.palette_open
- && !ctx.egui_wants_keyboard_input()
+ && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input())
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
{
- if self.is_preparing() {
+ if pages_owns_keyboard {
+ self.views[i].select_all_pages();
+ } else if self.is_preparing() {
if let Some(doc) = self.session.get(self.views[i].id) {
prepare::select_all(&mut self.views[i], &doc.form);
}
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index bdd625804d..01f5b74fac 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -1003,11 +1003,17 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
/// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging
/// moves the selection, or the page grabbed, to the gap it is dropped on.
fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, editable: bool, nav: &mut Option) {
+ let focus_id = ui.make_persistent_id(("pages-keyboard", view.id));
+ view.pages_focus_id = Some(focus_id);
+ let keyboard = ui.interact(ui.clip_rect(), focus_id, Sense::focusable_noninteractive());
+ let owns_keyboard = keyboard.has_focus();
+ // Escape belongs to this panel while it owns the keyboard, even after pointer movement.
+ ui.memory_mut(|m| m.set_focus_lock_filter(focus_id, egui::EventFilter { escape: true, ..Default::default() }));
// Escape drops the selection while the pointer is over the panel and nothing else wants the key.
if !modal
&& !view.selected.is_empty()
- && !ui.ctx().egui_wants_keyboard_input()
- && ui.rect_contains_pointer(ui.clip_rect())
+ && (owns_keyboard || !ui.ctx().egui_wants_keyboard_input())
+ && (owns_keyboard || ui.rect_contains_pointer(ui.clip_rect()))
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
{
view.clear_page_selection(Some(view.current));
@@ -1015,8 +1021,8 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
// Delete (or Backspace) deletes the selection, or the current page, likewise.
if !modal
&& editable
- && !ui.ctx().egui_wants_keyboard_input()
- && ui.rect_contains_pointer(ui.clip_rect())
+ && (owns_keyboard || !ui.ctx().egui_wants_keyboard_input())
+ && (owns_keyboard || ui.rect_contains_pointer(ui.clip_rect()))
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
{
delete_pages(view, info.pages.len());
@@ -1068,6 +1074,7 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { t.accent_text } else { t.text_muted }));
let resp = theme::hand(resp);
if resp.clicked() {
+ ui.memory_mut(|m| m.request_focus(focus_id));
let m = ui.input(|i| i.modifiers);
if m.shift || m.command {
view.click_page(i, m, true);
diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs
index d3744dd2ef..8405e05c80 100644
--- a/crates/ui-egui/tests/view.rs
+++ b/crates/ui-egui/tests/view.rs
@@ -52,6 +52,58 @@ fn rect(h: &Harness<'static, PdfCraftApp>, page: usize) -> Option {
h.state().views[0].page_screen_rect(page)
}
+fn sidebar_key(h: &mut Harness<'static, PdfCraftApp>, key: Key, modifiers: Modifiers) {
+ h.event(egui::Event::ModifiersChanged(modifiers));
+ for pressed in [true, false] {
+ h.event(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
+ }
+ h.run_steps(2);
+ h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
+ h.run_steps(1);
+}
+
+#[test]
+fn pages_sidebar_select_all_keeps_current_page_and_keyboard_scope() {
+ use egui_kittest::kittest::Queryable;
+ let mut h = harness(&[("panel", "pages")]);
+ h.get_by_label("Page 2").click();
+ h.run_steps(3);
+ assert_eq!(h.state().views[0].current, 1);
+ // Moving the pointer is not a keyboard-focus change.
+ h.hover_at(rect(&h, 1).unwrap().center());
+ h.run_steps(1);
+ for _ in 0..2 {
+ sidebar_key(&mut h, Key::A, Modifiers::COMMAND);
+ assert_eq!(h.state().views[0].target_pages(), vec![0, 1, 2]);
+ assert_eq!(h.state().views[0].current, 1, "selecting does not navigate");
+ }
+ sidebar_key(&mut h, Key::Escape, Modifiers::NONE);
+ assert!(h.state().views[0].selected.is_empty());
+
+ // A click on the viewer returns Ctrl+A to text selection, even with Pages open.
+ let pos = rect(&h, 1).unwrap().center();
+ for pressed in [true, false] {
+ h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
+ }
+ h.run_steps(3);
+ sidebar_key(&mut h, Key::A, Modifiers::COMMAND);
+ assert!(h.state().views[0].selected.is_empty(), "viewer shortcut must not select thumbnails");
+
+ // A different document does not inherit the previous sidebar's keyboard ownership.
+ h.get_by_label("Page 2").click();
+ h.run_steps(2);
+ h.state_mut().open_bytes("other.pdf", None, PAGES.to_vec()).unwrap();
+ h.run_steps(5);
+ sidebar_key(&mut h, Key::A, Modifiers::COMMAND);
+ let active = h.state().active.unwrap();
+ assert!(h.state().views[active].selected.is_empty());
+ h.get_by_label("Page 1").click();
+ h.run_steps(2);
+ sidebar_key(&mut h, Key::A, Modifiers::COMMAND);
+ assert_eq!(h.state().views[active].target_pages(), vec![0, 1, 2]);
+ assert!(h.state().views[0].selected.is_empty(), "selection remains document-local");
+}
+
/// Two pages of different sizes: 300×400 then 600×300.
const MIXED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
diff --git a/docs/roadmap.md b/docs/roadmap.md
index fabb5e603d..142466a0bd 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# PdfCraft roadmap: milestones and what's next
-> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M4/#2052 candidate: keyboard-owned Pages sidebar select-all; 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 (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)
@@ -95,7 +95,7 @@ total matches the parity estimate.
| 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). |
-| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web |
+| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize; #2052 candidate adds keyboard-owned Pages sidebar select-all without navigation, with focus and document-isolation regression coverage, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web |
| 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). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools. Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
@@ -162,6 +162,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | M4/#2052 candidate: Pages sidebar select-all, focus ownership, Escape and document isolation validated; estimates unchanged. |
| 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 359a71e9a6..c18ae42a21 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -1250,8 +1250,8 @@ 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."
+evidence = ["crates/ui-egui/tests/view.rs::pages_sidebar_select_all_keeps_current_page_and_keyboard_scope", "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 = "Ctrl/Cmd+A selects every page when the Pages sidebar owns the keyboard, without navigation; Escape clears the selection. Viewer and text-field shortcuts retain their own scope. 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"
From 74eef8cc0f35bf342ac61fd2d5b310e6d5efe156 Mon Sep 17 00:00:00 2001
From: JM
Date: Wed, 14 Oct 2026 17:04:46 +0000
Subject: [PATCH 010/270] 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 1d5c20c5c41ac268110bcf67e3638a69440592eb Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:02:36 +0200
Subject: [PATCH 011/270] Support typed font sizes and Fill and Sign text
colours (#2444, #2496)
---
ROADMAP.md | 2 +
crates/automation/README.md | 7 +++
crates/automation/src/comments.rs | 9 ++-
crates/automation/src/tools.rs | 3 +-
crates/automation/tests/automation.rs | 32 +++++++++++
crates/ui-egui/src/canvas.rs | 9 +++
crates/ui-egui/src/content_ui.rs | 83 +++++++++++++++++++++++++--
crates/ui-egui/src/editing.rs | 2 +-
crates/ui-egui/src/fill_sign.rs | 34 +++++++++--
crates/ui-egui/src/lib.rs | 9 +++
crates/ui-egui/tests/control.rs | 10 +++-
crates/ui-egui/tests/editing.rs | 35 +++++++++++
crates/ui-egui/tests/fill_sign.rs | 51 +++++++++++++++-
docs/roadmap.md | 2 +
parity/acrobat-features.toml | 12 ++--
15 files changed, 275 insertions(+), 25 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index fe9723fdf6..88625a7f41 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,5 +1,7 @@
# PdfCraft roadmap
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
+
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 4e27702a32..ddd844df1e 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -209,3 +209,10 @@ behavior. Back/Forward
returns to page tops, does not restore zoom, does not switch documents, and is not saved across
restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate
navigation-history tool; use the opt-in UI control channel to navigate the displayed document.
+
+## Fill & Sign text appearance
+
+`fill_sign_add` accepts `font_size` (4–300 pt, default 10) and `color`
+(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the
+FreeText appearance and survives reopening. Other mark/signature types reject
+`color`; their appearance controls are separate.
diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs
index 302688f729..481d75f741 100644
--- a/crates/automation/src/comments.rs
+++ b/crates/automation/src/comments.rs
@@ -477,6 +477,10 @@ impl Automation {
if sensitivity.is_some() && !scan {
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
}
+ let requested_color = a.color("color")?;
+ if requested_color.is_some() && !matches!(kind, "text" | "date") {
+ return Err(ToolError::InvalidArgs("color is only for type text or date".into()));
+ }
let requested_size = a.opt_num("font_size")?;
if requested_size.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("font_size is only for type text or date".into()));
@@ -555,7 +559,10 @@ impl Automation {
(Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new())
}
};
- let style = Style::default_for(&shape);
+ let mut style = Style::default_for(&shape);
+ if let Some(color) = requested_color {
+ style.color = color;
+ }
self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author }))
}
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 5e89aef78a..1bf59596f6 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -1162,7 +1162,7 @@ pub fn tools() -> Vec {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
- "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
+ "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
@@ -1173,6 +1173,7 @@ pub fn tools() -> Vec {
"at": point(),
"text": { "type": "string", "minLength": 1 },
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
+ "color": color(),
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 1e662d8f38..21b1794cf1 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -2785,6 +2785,38 @@ fn fill_sign_text_font_size_is_saved_and_validated() {
));
}
+#[test]
+fn fill_sign_text_and_date_colours_are_saved_and_validated() {
+ let dir = workdir("fill-sign-text-colour");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (kind, y) in [("text", 40), ("date", 80)] {
+ ok(
+ &mut a,
+ "fill_sign_add",
+ json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "text": "Blue", "color": "#3366CC", "font_size": 6.5 }),
+ );
+ }
+ for (kind, color) in [("text", "not-a-colour"), ("signature", "#3366CC"), ("check", "#3366CC")] {
+ assert!(matches!(
+ a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 100], "text": "No", "color": color })),
+ Err(ToolError::InvalidArgs(_))
+ ));
+ }
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "coloured.pdf" }));
+ let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("coloured.pdf")).unwrap())).unwrap();
+ let page = pdfcraft_model::pages(&pdf).remove(0);
+ let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap());
+ let annotations = annotations.as_array().unwrap();
+ assert_eq!(annotations.len(), 2, "invalid requests add nothing");
+ for reference in annotations {
+ let annotation = pdf.resolve(reference);
+ // FreeText stores its text colour in DA; C is the annotation background.
+ let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap();
+ assert_eq!(String::from_utf8(da.bytes.to_vec()).unwrap(), "0.2 0.4 0.8 rg /Helv 6.5 Tf");
+ }
+}
+
#[test]
fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
let dir = workdir("image-signatures");
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 989b731b7e..42454d7d9a 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2850,6 +2850,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
info,
ft,
app.fill_text_size,
+ app.fill_text_color,
view,
signature.as_ref(),
initials.as_ref(),
@@ -4436,6 +4437,14 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
.suffix(" pt"),
);
}
+ if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
+ ui.menu_button(tl!("Colour"), |ui| {
+ if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) {
+ app.fill_text_color = color;
+ ui.close();
+ }
+ });
+ }
if let QuickTool::Comment(tool) = app.quick_tool {
let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover());
ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 5f5ef32f77..110e608150 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -605,6 +605,43 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option {
}
}
+/// Commit a typed size once (Enter or leaving the field), rather than one undo step per key.
+/// The bounds match AddedText validation, so the panel also accepts coloured text below 8 pt.
+fn font_size_input(ui: &mut egui::Ui, size: &mut f64) {
+ let id = ui.make_persistent_id("font-size-input");
+ let (previous, mut text) = ui.data(|d| d.get_temp::<(u64, String)>(id)).unwrap_or_else(|| (size.to_bits(), size.to_string()));
+ if previous != size.to_bits() {
+ text = size.to_string();
+ }
+ let response = ui.add(egui::TextEdit::singleline(&mut text).id(id).desired_width(44.0));
+ response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), tl!("Font size:")));
+ let cancel = response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape));
+ if cancel {
+ text = size.to_string();
+ response.surrender_focus();
+ } else if response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
+ if let Some(value) = typed_font_size(&text) {
+ *size = value;
+ }
+ text = size.to_string();
+ }
+ let before = *size;
+ egui::ComboBox::from_id_salt("font-size").selected_text("pt").width(28.0).show_ui(ui, |ui| {
+ for value in [4.0, 6.0, 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(size, value, format!("{value} pt"));
+ }
+ });
+ if *size != before {
+ text = size.to_string();
+ }
+ ui.data_mut(|d| d.insert_temp(id, (size.to_bits(), text)));
+}
+
+fn typed_font_size(text: &str) -> Option {
+ let value = text.trim().parse::().ok()?;
+ (value.is_finite() && (1.0..=500.0).contains(&value)).then_some(value)
+}
+
/// Format text (shown in the Edit panel while a text item is selected, or for new text).
/// 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 {
@@ -616,12 +653,7 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) ->
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"));
- }
- });
+ font_size_input(ui, &mut s.size);
});
ui.horizontal(|ui| {
if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() {
@@ -911,6 +943,45 @@ mod tests {
use super::*;
+ #[test]
+ fn typed_sizes_accept_small_fractional_values_and_reject_invalid_input() {
+ for value in ["1", "4", "6.5", " 13.25 ", "500"] {
+ assert_eq!(typed_font_size(value), Some(value.trim().parse().unwrap()));
+ }
+ for value in ["", "0", "-1", "0.5", "501", "NaN", "inf", "1e999", "small"] {
+ assert_eq!(typed_font_size(value), None, "{value}");
+ }
+ }
+
+ #[test]
+ fn typed_size_commits_once_and_invalid_input_preserves_the_style() {
+ let mut h = egui_kittest::Harness::new_ui_state(
+ |ui, state: &mut (f64, usize)| {
+ let before = state.0;
+ ui.horizontal(|ui| font_size_input(ui, &mut state.0));
+ if state.0 != before {
+ state.1 += 1;
+ }
+ },
+ (12.0, 0),
+ );
+ h.run();
+ let enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
+ let select_all =
+ || egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
+ for (text, expected, changes) in [("6.5", 6.5, 1), ("NaN", 6.5, 1), ("0", 6.5, 1), ("13.25", 13.25, 2)] {
+ h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Font size:").click();
+ h.run();
+ h.event(select_all());
+ h.event(egui::Event::Text(text.into()));
+ h.run_steps(2);
+ assert_eq!(h.state().1, changes - usize::from(text == "6.5" || text == "13.25"), "typing alone does not commit");
+ h.event(enter());
+ h.run_steps(2);
+ assert_eq!(*h.state(), (expected, changes));
+ }
+ }
+
/// The image a flip button leaves behind, as the panel returns it.
fn flipped(tooltip: &str) -> pdfcraft_engine::AddedImage {
let img = pdfcraft_engine::AddedImage::new([0.0, 0.0, 100.0, 50.0], pdfcraft_cos::ObjRef::new(7, 0));
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index be1347d68d..38a26b871b 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -531,7 +531,7 @@ impl PdfCraftApp {
}
let author = self.comment_prefs.author.clone();
let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page));
- let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, rotation, &author);
+ let edit = crate::fill_sign::typed_with_color(tb.page, tb.at, &text, tb.size, rotation, &author, tb.color);
if self.apply_edit(edit) {
if let Some(view) = self.views.get_mut(i) {
view.fill_text = None;
diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs
index 794fe0bf1b..084b5290f4 100644
--- a/crates/ui-egui/src/fill_sign.rs
+++ b/crates/ui-egui/src/fill_sign.rs
@@ -404,6 +404,7 @@ pub struct TypeBox {
pub at: [f64; 2],
pub text: String,
pub size: f64,
+ pub color: [f64; 3],
pub focus: bool,
}
@@ -416,6 +417,10 @@ pub fn valid_text_size(size: f64) -> Option {
(size.is_finite() && (MIN_TEXT_SIZE..=MAX_TEXT_SIZE).contains(&size)).then_some(size)
}
+pub fn valid_text_color(color: [f64; 3]) -> Option<[f64; 3]> {
+ color.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)).then_some(color)
+}
+
fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
let (vx, vy) = xf.screen_to_view(p);
let u = info.pages[page].view_to_user(vx, vy);
@@ -430,8 +435,16 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit {
/// A typewriter annotation sized to its text, its displayed top-left at `at` on a page turned by
/// `rotation` (its `/Rotate`).
pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str) -> Edit {
+ typed_with_color(page, at, text, size, rotation, author, [0.0; 3])
+}
+
+/// Typed text/date colour applies to its annotation appearance and survives saving.
+pub fn typed_with_color(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, color: [f64; 3]) -> Edit {
let rect = crate::comments::text_box_rect(at, text, size, rotation);
- new(page, Shape::Typewriter { rect, font_size: size }, text.to_string(), author)
+ let shape = Shape::Typewriter { rect, font_size: size };
+ let mut style = Style::default_for(&shape);
+ style.color = valid_text_color(color).unwrap_or([0.0; 3]);
+ Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: text.to_string(), author: author.to_string() })
}
/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`,
@@ -620,6 +633,7 @@ pub(crate) fn page_input(
info: &DocInfo,
tool: FillTool,
text_size: f64,
+ text_color: [f64; 3],
view: &mut DocView,
signature: Option<&SavedSig>,
initials: Option<&SavedSig>,
@@ -657,13 +671,21 @@ pub(crate) fn page_input(
FillTool::Text => {
// Clicking away from an open box commits it: the page sees this click before the box
// sees its lost focus, so replacing the box first would drop what was typed.
- let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, focus: true });
+ let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true });
open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| {
- FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author)))
+ FillAction::Edit(Box::new(typed_with_color(
+ tb.page,
+ tb.at,
+ tb.text.trim(),
+ tb.size,
+ crate::comments::page_rotation(info, tb.page),
+ author,
+ tb.color,
+ )))
})
}
FillTool::Date => Some(match date_text {
- Ok(text) => FillAction::Edit(Box::new(typed(page, top, text, text_size, rotation, author))),
+ Ok(text) => FillAction::Edit(Box::new(typed_with_color(page, top, text, text_size, rotation, author, text_color))),
Err(why) => FillAction::Refused(why.clone()),
}),
FillTool::Signature => match signature {
@@ -719,7 +741,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
.font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0)))
.desired_width(width)
.background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230))
- .text_color(Color32::BLACK)
+ .text_color(crate::comments::color32(t.color))
.hint_text(tl!("Type text"))
.id_salt("fill-text-edit"),
);
@@ -740,7 +762,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
if commit {
let tb = view.fill_text.take()?;
if !tb.text.trim().is_empty() {
- return Some(typed(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author));
+ return Some(typed_with_color(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color));
}
}
None
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index d101d812e0..576784e190 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -550,6 +550,8 @@ pub struct PdfCraftApp {
pub flatten_fill_sign_on_save: bool,
/// Point size for new Fill & Sign text and dates, kept across launches.
pub fill_text_size: f64,
+ /// Colour for new Fill & Sign text and dates, kept across launches.
+ pub fill_text_color: [f64; 3],
pub dialog: Option,
/// How to ask for the latest release (the desktop app sets it; see `updates`).
pub update_source: Option,
@@ -921,6 +923,7 @@ impl PdfCraftApp {
ui_scale_applied: None,
flatten_fill_sign_on_save: false,
fill_text_size: fill_sign::TEXT_SIZE,
+ fill_text_color: [0.0; 3],
dialog: None,
update_source: None,
updates: updates::Updates::default(),
@@ -2276,6 +2279,7 @@ impl PdfCraftApp {
"right_panel_closed": self.right_panel_closed,
});
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
+ v["fill_text_color"] = serde_json::json!(self.fill_text_color);
v["redact_prefs"] = self.redact_defaults.to_json();
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
@@ -2357,6 +2361,11 @@ 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 Ok(color) = serde_json::from_value::<[f64; 3]>(v["fill_text_color"].clone())
+ && let Some(color) = fill_sign::valid_text_color(color)
+ {
+ self.fill_text_color = color;
+ }
if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) {
self.redact_prefs = prefs.clone();
self.redact_defaults = prefs;
diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs
index 88ed1c0a90..a93e035c77 100644
--- a/crates/ui-egui/tests/control.rs
+++ b/crates/ui-egui/tests/control.rs
@@ -1425,8 +1425,14 @@ fn rail_reveals_its_remembered_inactive_inspector_then_closes_the_visible_panel(
#[test]
fn control_quit_retains_inactive_fill_sign_typing_after_cancel() {
let (mut h, c) = harness();
- h.state_mut().views[0].fill_text =
- Some(pdfcraft_ui_egui::fill_sign::TypeBox { page: 0, at: [40.0, 200.0], text: "Unsaved signature note".into(), size: 10.0, focus: false });
+ h.state_mut().views[0].fill_text = Some(pdfcraft_ui_egui::fill_sign::TypeBox {
+ page: 0,
+ at: [40.0, 200.0],
+ text: "Unsaved signature note".into(),
+ size: 10.0,
+ color: [0.0; 3],
+ focus: false,
+ });
h.state_mut().active = None;
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting":false,"needs_confirmation":true}));
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index f8db525751..4a31784246 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -2876,6 +2876,41 @@ fn the_format_panel_restyles_the_paragraph_being_edited() {
assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline));
}
+#[test]
+fn typed_small_coloured_text_size_survives_save_and_reopen() {
+ let mut h = harness(1, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ let r = h.state().views[0].page_screen_rect(0).unwrap();
+ let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
+ h.hover_at(at);
+ h.run_steps(1);
+ h.drag_at(at);
+ h.run_steps(1);
+ h.drop_at(at);
+ h.run_steps(3);
+ assert!(h.state().views[0].line_editor.is_some());
+ h.get_by_label("Font size:").click();
+ h.run_steps(1);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::A);
+ h.event(egui::Event::Text("6.5".into()));
+ h.key_press(Key::Enter);
+ h.run_steps(4);
+ h.get_by_label("Blue").click();
+ h.run_steps(4);
+ let id = h.state().views[0].id;
+ let lines = h.state().session.get(id).unwrap().text_lines(0);
+ assert!(!lines.is_empty());
+ for line in &lines {
+ assert!((line.size - 6.5).abs() < 0.01, "{line:?}");
+ assert!(line.color[2] > line.color[0], "the small text stays blue: {line:?}");
+ }
+ let saved = h.state().session.save_bytes(id).unwrap();
+ let mut reopened = pdfcraft_engine::Session::new();
+ let rid = reopened.open("small-blue.pdf", None, saved, None).unwrap();
+ assert_eq!(reopened.get(rid).unwrap().text_lines(0), lines);
+}
+
#[test]
fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() {
let mut h = harness(1, |_| {});
diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs
index 27fd6dd8a5..b888e1e23e 100644
--- a/crates/ui-egui/tests/fill_sign.rs
+++ b/crates/ui-egui/tests/fill_sign.rs
@@ -168,6 +168,53 @@ fn fill_text_size_controls_new_text_and_survives_settings_restore() {
assert_eq!(restored.fill_text_size, 24.0, "invalid settings do not change the size");
}
+#[test]
+fn fill_text_colour_survives_preview_commit_settings_and_save() {
+ let mut h = harness();
+ h.state_mut().execute("sign.fill.text");
+ h.run_steps(2);
+ h.get_by_label("Colour").click();
+ h.run_steps(2);
+ h.get_by_label("Blue").click();
+ h.run_steps(2);
+ let color = h.state().fill_text_color;
+ assert!(color[2] > color[0], "blue was selected: {color:?}");
+ click(&mut h, 50.0, 250.0);
+ assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().color, color, "the editor uses the selected colour");
+ h.event(egui::Event::Text("Blue form text".into()));
+ h.run_steps(2);
+ h.key_press(egui::Key::Enter);
+ h.run_steps(3);
+ let id = h.state().views[0].id;
+ let expected = color.map(|v| v as f32);
+ let annotation = h.state().session.get(id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Blue form text")).unwrap();
+ assert_eq!(annotation.color, Some(expected));
+ let bytes = h.state().session.save_bytes(id).unwrap();
+ let reopened = harness_bytes(&bytes);
+ let saved = reopened
+ .state()
+ .session
+ .get(reopened.state().views[0].id)
+ .unwrap()
+ .info
+ .annotations
+ .iter()
+ .find(|a| a.contents.as_deref() == Some("Blue form text"))
+ .unwrap();
+ assert_eq!(saved.color, Some(expected));
+ let mut restored = PdfCraftApp::new();
+ restored.restore(&h.state().persist());
+ assert_eq!(restored.fill_text_color, color);
+ restored.restore(r#"{"fill_text_color":[2,0,0]}"#);
+ assert_eq!(restored.fill_text_color, color, "invalid saved colours are ignored");
+ h.state_mut().execute("edit.undo");
+ h.run_steps(2);
+ assert!(!items(&h).iter().any(|(_, text)| text.as_deref() == Some("Blue form text")));
+ h.state_mut().execute("edit.redo");
+ h.run_steps(2);
+ assert!(items(&h).iter().any(|(_, text)| text.as_deref() == Some("Blue form text")));
+}
+
#[test]
fn the_type_box_grows_as_text_is_typed() {
let mut h = harness();
@@ -995,7 +1042,7 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() {
app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap();
let id = app.views[0].id;
app.session.apply(id, mark).unwrap();
- app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, focus: false });
+ app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false });
let kept = dir.join("kept.pdf");
app.save_override = Some(kept.to_string_lossy().into_owned());
assert!(app.save_active(SaveTarget::InPlace));
@@ -1006,7 +1053,7 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() {
assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}");
assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too");
- app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, focus: false });
+ app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, color: [0.0; 3], focus: false });
app.flatten_fill_sign_on_save = true;
let flat = dir.join("flat.pdf");
app.save_override = Some(flat.to_string_lossy().into_owned());
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 3148823ea5..ffb34ffd7a 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,5 +1,7 @@
# PdfCraft roadmap: milestones and what's next
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
+
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; 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 (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 533013b547..7c35e73b5a 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -1926,8 +1926,8 @@ 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"
+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", "crates/ui-egui/src/content_ui.rs::typed_sizes_accept_small_fractional_values_and_reject_invalid_input", "crates/ui-egui/src/content_ui.rs::typed_size_commits_once_and_invalid_input_preserves_the_style", "crates/ui-egui/tests/editing.rs::typed_small_coloured_text_size_survives_save_and_reopen"]
+notes = "existing paragraphs and added text: font family (Helvetica, Times, Courier) with bold and italic, size, colour; existing paragraphs are rewrapped; the Edit font-size field accepts typed fractional values from 1–500 pt, including below 8 pt; Enter/focus loss commits once and invalid input preserves the previous value"
[[feature]]
id = "edit.text-format-style"
@@ -5268,8 +5268,8 @@ 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"
+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", "crates/ui-egui/tests/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"]
+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; text/date colour is selectable, remembered and preserved in saved appearances; fill_sign_add exposes color and font_size"
[[feature]]
id = "form.fill-sign-marks"
@@ -5292,8 +5292,8 @@ 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"
+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", "crates/automation/tests/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"]
+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; shares the Fill & Sign text colour preference and tool argument"
[[feature]]
id = "form.fill-sign-signature"
From a9b0db12c62f81beb8d2b57851d650d10bdb37e8 Mon Sep 17 00:00:00 2001
From: alehel
Date: Fri, 16 Oct 2026 10:46:02 +0200
Subject: [PATCH 012/270] fix(edit): show backgrounds on pages that paint their
own white backdrop
Add Background draws behind the page content, so on a page that first
fills itself with opaque white (browsers' Save as PDF, print drivers) the
background was hidden completely and the tool looked broken.
- pdfcraft-edit: new `backdrop` module. A strictly detected backdrop (one
axis-aligned `re`, opaque white in a device colour space, painted before
anything else and covering the crop box) is replaced by turning that one
fill into `n` in a per-page copy of its stream; the original stream is
left untouched for other pages. Remove background restores it, unless
the copy was edited since. Page-sized pictures (scans) are reported only.
- Background gains `replace_backdrop` (default on); the default colour is
a visible light yellow instead of near-white.
- Dialog: offers to replace the backdrop, warns about scanned pages,
checks pages a few per frame, preview draws text over the colour.
- Automation: `doc_background` takes `replace_backdrop`; new read-only
`doc_backdrops` lists pages with a backdrop.
Acrobat's behaviour on such pages is unobserved; recorded as our choice
in parity/.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/automation/src/lib.rs | 23 ++
crates/automation/src/tools.rs | 8 +-
crates/automation/tests/backdrop.rs | 109 +++++++++
crates/edit/src/backdrop.rs | 323 +++++++++++++++++++++++++
crates/edit/src/backdrop_tests.rs | 233 ++++++++++++++++++
crates/edit/src/lib.rs | 22 +-
crates/engine/src/lib.rs | 7 +-
crates/ui-egui/src/marks_ui.rs | 101 +++++++-
crates/ui-egui/tests/ui_group/marks.rs | 67 +++++
parity/acrobat-features.toml | 10 +-
11 files changed, 892 insertions(+), 12 deletions(-)
create mode 100644 crates/automation/tests/backdrop.rs
create mode 100644 crates/edit/src/backdrop.rs
create mode 100644 crates/edit/src/backdrop_tests.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index 259dbfec04..b90b9f5679 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background puts the original back unless the copy was edited since. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 88856305b2..564dd8e882 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -704,6 +704,7 @@ impl Automation {
}
"doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
+ "doc_backdrops" => self.backdrops(&a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
out["security"] = security(self.doc(&a)?);
@@ -1172,6 +1173,27 @@ impl Automation {
Ok(out)
}
+ /// Pages whose own backdrop hides a background (see `pdfcraft_edit::backdrop`).
+ fn backdrops(&mut self, a: &Args) -> Result {
+ let n = self.doc(a)?.info.pages.len();
+ let pages = match a.opt_ints("pages")? {
+ Some(_) => self.pages(a, "pages")?,
+ None => (0..n).collect(),
+ };
+ let doc = self.doc(a)?;
+ let found: Vec = pages
+ .into_iter()
+ .filter_map(|p| {
+ let kind = match doc.page_backdrop(p)? {
+ pdfcraft_engine::Backdrop::Fill => "fill",
+ pdfcraft_engine::Backdrop::Image => "image",
+ };
+ Some(json!({ "page": p + 1, "backdrop": kind }))
+ })
+ .collect();
+ Ok(json!({ "pages": found }))
+ }
+
fn marks(&mut self, tool: &str, a: &Args) -> Result {
use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark};
let n = self.doc(a)?.info.pages.len();
@@ -1230,6 +1252,7 @@ impl Automation {
color: color("color", [1.0; 3])?,
opacity: a.opt_num("opacity")?.unwrap_or(1.0),
scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0),
+ replace_backdrop: a.opt_bool("replace_backdrop")?.unwrap_or(true),
..Background::default()
},
replace,
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 5e89aef78a..e520b7bb63 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -902,12 +902,16 @@ pub fn tools() -> Vec {
}),
&["doc"],
)),
- t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. Undoable.")
+ t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. A page that paints its own opaque white backdrop would hide it, so that backdrop is replaced unless replace_backdrop is false (removing the background puts it back); a scanned page's picture still hides it (doc_backdrops lists both). Undoable.")
.cmd("edit.background")
.with(schema(
- json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }),
+ json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }),
&["doc"],
)),
+ t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.")
+ .ro()
+ .cmd("edit.background")
+ .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])),
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
diff --git a/crates/automation/tests/backdrop.rs b/crates/automation/tests/backdrop.rs
new file mode 100644
index 0000000000..1a8a71eb8e
--- /dev/null
+++ b/crates/automation/tests/backdrop.rs
@@ -0,0 +1,109 @@
+//! Add Background on pages that paint their own backdrop, end to end through the tools: what
+//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back.
+
+use std::path::PathBuf;
+
+use pdfcraft_automation::{Automation, Content};
+use serde_json::{Value, json};
+
+/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write),
+/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture).
+fn fixture() -> Vec {
+ let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET";
+ let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")];
+ let mut objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(),
+ "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream"
+ .into(),
+ ];
+ for (i, c) in contents.iter().enumerate() {
+ objs.push(format!(
+ "<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>",
+ 5 + 2 * i
+ ));
+ objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len()));
+ }
+ 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());
+ out
+}
+
+fn workdir() -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("a.pdf"), fixture()).unwrap();
+ dir
+}
+
+fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
+ match a.call(tool, &args) {
+ Ok(mut c) => match c.remove(0) {
+ Content::Json(v) => v,
+ other => panic!("{tool}: expected JSON, got {other:?}"),
+ },
+ Err(e) => panic!("{tool} {args}: {e}"),
+ }
+}
+
+/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi.
+fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] {
+ let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
+ let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") };
+ let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
+ let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
+ let info = reader.next_frame(&mut pixels).unwrap();
+ let channels = info.color_type.samples();
+ let at = (10 * *width as usize + 180) * channels;
+ [pixels[at], pixels[at + 1], pixels[at + 2]]
+}
+
+const RED: [u8; 3] = [255, 0, 0];
+const WHITE: [u8; 3] = [255, 255, 255];
+
+#[test]
+fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() {
+ let dir = workdir();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(
+ ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"],
+ json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }])
+ );
+
+ // Not replacing: the page's own white hides the background, as it always did.
+ ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false }));
+ assert_eq!(blank_spot(&mut a, doc, 1), WHITE);
+ assert_eq!(blank_spot(&mut a, doc, 2), RED);
+ ok(&mut a, "edit_undo", json!({ "doc": doc }));
+
+ // The default replaces it: red on both pages; the scan still hides it.
+ ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
+ assert_eq!(blank_spot(&mut a, doc, 1), RED);
+ assert_eq!(blank_spot(&mut a, doc, 2), RED);
+ assert_ne!(blank_spot(&mut a, doc, 3), RED);
+ assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }]));
+
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
+ let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen");
+ ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" }));
+ assert_eq!(blank_spot(&mut a, saved, 1), WHITE);
+ assert_eq!(
+ ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"],
+ json!([{ "page": 1, "backdrop": "fill" }]),
+ "the backdrop is back"
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs
new file mode 100644
index 0000000000..d15652f82b
--- /dev/null
+++ b/crates/edit/src/backdrop.rs
@@ -0,0 +1,323 @@
+//! A page's own backdrop: an opaque white fill (or a picture) covering the whole visible page
+//! before anything else is drawn. Many writers paint one (browsers' Save as PDF, print drivers;
+//! scans are a page-sized picture), and it hides anything placed behind the content, so a
+//! background added behind such a page never shows.
+//!
+//! A white fill can be replaced: the one fill operator becomes `n` (end the path without
+//! painting), which looks the same on white paper. The page then gets a copy of the content
+//! stream that held it, tagged `/PCBackdrop << /Original ref /At n /Len n >>`, and the stream
+//! itself is left alone (other pages may share it). [`restore`] puts the original back when the
+//! background is removed, unless the copy has been edited since. A picture can't be replaced
+//! without losing it, so it is only reported.
+//!
+//! Detection is deliberately strict: only one rectangle (`re`), in DeviceGray, DeviceRGB or
+//! DeviceCMYK, opaque in the graphics state, with an axis-aligned transform, filled before any
+//! other painting, and covering the crop box. Anything else is real content and stays.
+
+use pdfcraft_content::{Matrix, Op, Pieces};
+use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
+
+use crate::EditError;
+
+/// How much of a page's content is read looking for a backdrop: it has to come first.
+const SCAN_BYTES: usize = 64 * 1024;
+/// How many operators are walked before giving up.
+const SCAN_OPS: usize = 4096;
+/// How far (in points) a backdrop may fall short of the crop box and still count as covering it.
+const COVER_EPS: f64 = 1.0;
+/// Components at least this close to white (per channel) count as white.
+const WHITE: f64 = 0.98;
+
+/// What paints a page's backdrop.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Backdrop {
+ /// An opaque white fill: replaceable.
+ Fill,
+ /// A picture covering the page (a scan): a background behind it stays hidden.
+ Image,
+}
+
+/// Where a replaceable fill is: the content streams (references in `/Contents` order, untagged
+/// ones only), their joined bytes, and the fill operator's joined byte range.
+struct Found {
+ refs: Vec<(usize, ObjRef)>,
+ pieces: Pieces,
+ op: std::ops::Range,
+}
+
+#[derive(Clone)]
+struct State {
+ ctm: Matrix,
+ /// Clip in default user space, when it is a known rectangle; `None` = unknown shape.
+ clip: Option<[f64; 4]>,
+ /// The fill colour is white.
+ white: bool,
+ /// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read.
+ space: usize,
+ opaque: bool,
+}
+
+/// The backdrop `page` (0-based) paints, if any.
+pub fn page_backdrop(doc: &Document, page: usize) -> Result, EditError> {
+ Ok(scan(doc, page)?.map(|(b, _)| b))
+}
+
+/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it
+/// did. A picture backdrop is left alone.
+pub(crate) fn replace(doc: &mut Document, page: usize) -> Result {
+ let Some((Backdrop::Fill, Some(found))) = scan(doc, page)? else { return Ok(false) };
+ let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]);
+ let piece = found.pieces.piece_of(found.op.start);
+ let (Some(&(slot, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) };
+ // Where the operator sits in that piece: the joined offset less the pieces before it.
+ let before: usize = (0..piece).filter_map(|i| edited.get(i)).map(|p| p.len() + 1).sum();
+ let at = found.op.start.saturating_sub(before);
+ let len = found.op.end.saturating_sub(found.op.start);
+ let mut note = Dict::new();
+ note.set(b"Original".to_vec(), Object::Ref(original));
+ note.set(b"At".to_vec(), Object::Int(i64::try_from(at).unwrap_or(i64::MAX)));
+ note.set(b"Len".to_vec(), Object::Int(i64::try_from(len).unwrap_or(i64::MAX)));
+ let mut dict = Dict::new();
+ dict.set(b"PCBackdrop".to_vec(), Object::Dict(note));
+ let copy = doc.add(Object::Stream(Stream::flate(dict, bytes)));
+ let p = page_of(doc, page)?;
+ let mut list = crate::contents(doc, &p)?;
+ let Some(entry) = list.get_mut(slot) else { return Ok(false) };
+ *entry = Object::Ref(copy);
+ doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
+ Ok(true)
+}
+
+/// Put back the original content streams [`replace`] copied on `page`, where the copy is still
+/// exactly what it wrote. Returns how many it restored.
+pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result {
+ let mut list = crate::contents(doc, page)?;
+ let mut restored = 0;
+ for entry in list.iter_mut() {
+ if let Some(original) = unchanged_copy_of(doc, entry) {
+ *entry = Object::Ref(original);
+ restored += 1;
+ }
+ }
+ if restored > 0 {
+ doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
+ }
+ Ok(restored)
+}
+
+/// The original of a backdrop copy whose bytes are still the original's with the fill turned to
+/// `n`; `None` for anything else (including a copy edited since, which is kept).
+fn unchanged_copy_of(doc: &Document, entry: &Object) -> Option {
+ let copy = doc.resolve(entry);
+ let Object::Stream(copy) = &*copy else { return None };
+ let note = copy.dict.get(b"PCBackdrop").map(|n| doc.resolve(n))?;
+ let note = note.as_dict()?;
+ let Some(Object::Ref(original)) = note.get(b"Original") else { return None };
+ let at = usize::try_from(note.get(b"At")?.as_int()?).ok()?;
+ let len = usize::try_from(note.get(b"Len")?.as_int()?).ok()?;
+ let orig = doc.get(*original);
+ let Object::Stream(orig) = &*orig else { return None };
+ let source = orig.decoded().ok()?;
+ let mut expect = source.get(..at)?.to_vec();
+ expect.push(b'n');
+ expect.extend_from_slice(source.get(at.checked_add(len)?..)?);
+ (copy.decoded().ok()? == expect).then_some(*original)
+}
+
+fn page_of(doc: &Document, page: usize) -> Result {
+ pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
+}
+
+/// Find the backdrop, and for a fill, where it is.
+fn scan(doc: &Document, page: usize) -> Result)>, EditError> {
+ let p = page_of(doc, page)?;
+ let crop = p.crop(doc);
+ let list: Vec = match p.dict.get(b"Contents") {
+ None => return Ok(None),
+ Some(c) => match &*doc.resolve(c) {
+ Object::Array(a) => a.clone(),
+ _ => vec![c.clone()],
+ },
+ };
+ // The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read
+ // only as far as a backdrop could be.
+ let mut refs = Vec::new();
+ let mut data = Vec::new();
+ let mut total = 0usize;
+ for (slot, o) in list.iter().enumerate() {
+ if total >= SCAN_BYTES {
+ break;
+ }
+ let Object::Ref(r) = o else { return Ok(None) };
+ let obj = doc.get(*r);
+ let Object::Stream(s) = &*obj else { continue };
+ if s.dict.get(b"PCMark").is_some() {
+ continue;
+ }
+ let bytes = s.decoded().unwrap_or_default();
+ total = total.saturating_add(bytes.len()).saturating_add(1);
+ refs.push((slot, *r));
+ data.push(bytes);
+ }
+ let pieces = Pieces::join(&data);
+ let head = pieces.data().get(..pieces.data().len().min(SCAN_BYTES)).unwrap_or_default();
+ let parsed = pdfcraft_content::parse(head);
+ let res = resources(doc, &p.dict);
+ let found = walk(doc, &res, crop, &parsed.ops);
+ Ok(found.map(|(b, op)| match (b, op) {
+ (Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, op })),
+ _ => (b, None),
+ }))
+}
+
+fn resources(doc: &Document, page: &Dict) -> Dict {
+ page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default()
+}
+
+fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option> {
+ let d = res.get(kind).map(|d| doc.resolve(d))?;
+ let o = d.as_dict()?.get(name)?.clone();
+ Some(doc.resolve(&o))
+}
+
+/// Walk the operators until the first one that paints; a backdrop is that one, or nothing.
+fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backdrop, Option>)> {
+ let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, opaque: true };
+ let mut saved: Vec = Vec::new();
+ // Current path: its rectangles in user space, or `None` once it is anything else.
+ let mut path: Option> = Some(Vec::new());
+ let mut clip_next = false;
+ for op in ops.iter().take(SCAN_OPS) {
+ match op.op.as_slice() {
+ b"q" => {
+ if saved.len() >= 64 {
+ return None;
+ }
+ saved.push(st.clone());
+ }
+ b"Q" => st = saved.pop()?,
+ b"cm" => {
+ let m = Matrix::from_operands(op.operands.get(op.operands.len().checked_sub(6)?..)?)?;
+ if !m.0.iter().all(|v| v.is_finite()) {
+ return None;
+ }
+ st.ctm = m.then(&st.ctm);
+ }
+ b"gs" => st.opaque = opaque_state(doc, res, op.name(0)?)?,
+ b"g" => set_colour(&mut st, 1, op),
+ b"rg" => set_colour(&mut st, 3, op),
+ b"k" => set_colour(&mut st, 4, op),
+ b"cs" => {
+ st.space = match op.name(0)? {
+ b"DeviceGray" => 1,
+ b"DeviceRGB" => 3,
+ b"DeviceCMYK" => 4,
+ _ => 0,
+ };
+ // The initial colour of these spaces is black.
+ st.white = false;
+ }
+ b"sc" | b"scn" => {
+ let n = st.space;
+ set_colour(&mut st, n, op)
+ }
+ b"re" => {
+ let [x, y, w, h] = op.nums::<4>()?;
+ let finite = [x, y, w, h].iter().all(|v| v.is_finite());
+ match (&mut path, finite && axis_aligned(&st.ctm)) {
+ (Some(rects), true) => rects.push(st.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])),
+ _ => path = None,
+ }
+ }
+ b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => path = None,
+ b"W" | b"W*" => clip_next = true,
+ b"n" => {
+ if clip_next {
+ st.clip = clipped(st.clip, path.as_deref());
+ }
+ path = Some(Vec::new());
+ clip_next = false;
+ }
+ b"f" | b"F" | b"f*" => {
+ // One rectangle: with two, a fill rule can cut a hole where content shows.
+ let covers = match path.as_deref() {
+ Some([r]) => st.clip.and_then(|c| intersect(*r, c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)),
+ _ => false,
+ };
+ return (covers && st.white && st.opaque && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone())));
+ }
+ b"Do" => {
+ let xo = sub(doc, res, b"XObject", op.name(0)?)?;
+ let Object::Stream(s) = &*xo else { return None };
+ // A stencil or masked picture lets what is behind it show.
+ let masked = [&b"SMask"[..], b"Mask"].iter().any(|k| s.dict.get(k).is_some())
+ || s.dict.get(b"ImageMask").is_some_and(|m| matches!(*doc.resolve(m), Object::Bool(true)));
+ let image = s.dict.name(b"Subtype") == Some(b"Image") && !masked;
+ return covers_unit_square(&st, crop, image).then_some((Backdrop::Image, None));
+ }
+ b"BI" => {
+ let masked = op
+ .inline
+ .as_ref()
+ .is_some_and(|(d, _)| [&b"IM"[..], b"ImageMask"].iter().any(|k| d.get(k).is_some_and(|m| matches!(m, Object::Bool(true)))));
+ return covers_unit_square(&st, crop, !masked).then_some((Backdrop::Image, None));
+ }
+ // Anything else that paints comes first: no backdrop.
+ b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" | b"sh" | b"Tj" | b"TJ" | b"'" | b"\"" | b"EI" | b"d0" | b"d1" => return None,
+ _ => {}
+ }
+ }
+ None
+}
+
+/// A picture placed by the CTM (onto the unit square) covering the crop box, opaque, unclipped.
+fn covers_unit_square(st: &State, crop: [f64; 4], image: bool) -> bool {
+ image
+ && st.opaque
+ && axis_aligned(&st.ctm)
+ && st.clip.and_then(|c| intersect(st.ctm.bbox([0.0, 0.0, 1.0, 1.0]), c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS))
+}
+
+fn set_colour(st: &mut State, components: usize, op: &Op) {
+ st.space = components;
+ let vals: Vec = op.operands.iter().filter_map(Object::as_f64).collect();
+ st.white = vals.len() == components
+ && match components {
+ 1 | 3 => vals.iter().all(|v| *v >= WHITE),
+ 4 => vals.iter().all(|v| *v <= 1.0 - WHITE),
+ _ => false,
+ };
+}
+
+/// Does the named ExtGState keep painting opaque and normal? `None` when it can't be read.
+fn opaque_state(doc: &Document, res: &Dict, name: &[u8]) -> Option {
+ let gs = sub(doc, res, b"ExtGState", name)?;
+ let gs = gs.as_dict()?;
+ let alpha = gs.get(b"ca").map(|a| doc.resolve(a).as_f64()).unwrap_or(Some(1.0))?;
+ let soft = gs.get(b"SMask").is_some_and(|m| doc.resolve(m).as_name() != Some(b"None"));
+ let blend = gs.get(b"BM").is_some_and(|b| !matches!(doc.resolve(b).as_name(), Some(b"Normal" | b"Compatible")));
+ Some(alpha >= 0.999 && !soft && !blend)
+}
+
+/// Rotations by multiples of 90° and scales only: rectangles stay rectangles.
+fn axis_aligned(m: &Matrix) -> bool {
+ let [a, b, c, d, ..] = m.0;
+ (b.abs() < 1e-9 && c.abs() < 1e-9) || (a.abs() < 1e-9 && d.abs() < 1e-9)
+}
+
+fn intersect(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
+ let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
+ (r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
+}
+
+/// The clip after intersecting with a path: known only when the path is one rectangle.
+fn clipped(clip: Option<[f64; 4]>, path: Option<&[[f64; 4]]>) -> Option<[f64; 4]> {
+ match path {
+ Some([r]) => intersect(clip?, *r).or(Some([0.0, 0.0, 0.0, 0.0])),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+#[path = "backdrop_tests.rs"]
+mod tests;
diff --git a/crates/edit/src/backdrop_tests.rs b/crates/edit/src/backdrop_tests.rs
new file mode 100644
index 0000000000..88f058b4f5
--- /dev/null
+++ b/crates/edit/src/backdrop_tests.rs
@@ -0,0 +1,233 @@
+use std::sync::Arc;
+
+use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, Stream, write_incremental};
+
+use super::*;
+use crate::{Background, MarkKind, add_background, remove_marks};
+
+const TEXT: &str = "BT /F1 24 Tf 40 90 Td (Hello) Tj ET";
+
+/// A document from its objects (object 1 is the catalog, 2 the page tree).
+fn build(objs: &[String]) -> Document {
+ 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()
+}
+
+fn stream(dict: &str, data: &str) -> String {
+ format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
+}
+
+/// One 300 × 200 page drawing `pieces` (objects 5…), with a font, a page-sized image `/Im0`
+/// (object 4) and two graphics states, `/Half` (ca 0.5) and `/Full` (ca 1).
+fn page(pieces: &[&str]) -> Document {
+ let refs: Vec = (0..pieces.len()).map(|i| format!("{} 0 R", i + 5)).collect();
+ let mut objs = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ format!(
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << /Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> >> >> >>",
+ refs.join(" ")
+ ),
+ stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{80}"),
+ ];
+ objs.extend(pieces.iter().map(|p| stream("", p)));
+ build(&objs)
+}
+
+/// The decoded content of a page, stream by stream.
+fn streams(doc: &Document) -> Vec {
+ let p = &pdfcraft_model::pages(doc)[0];
+ let list = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() {
+ Some(Object::Array(a)) => a.clone(),
+ _ => vec![],
+ };
+ list.iter()
+ .map(|o| match &*doc.resolve(o) {
+ Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(),
+ _ => String::new(),
+ })
+ .collect()
+}
+
+fn reopen(doc: &Document) -> Document {
+ let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
+ Document::open(Arc::new(bytes)).unwrap()
+}
+
+fn red() -> Background {
+ Background { color: [1.0, 0.0, 0.0], ..Background::default() }
+}
+
+#[test]
+fn a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes() {
+ let content = format!("q 1 1 1 rg 0 0 300 200 re f Q {TEXT}");
+ let mut doc = page(&[&content]);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill));
+ let original = streams(&doc);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ let s = streams(&reopen(&doc));
+ assert_eq!(s.len(), 2, "{s:?}");
+ assert!(s[0].contains("/PCMark /Background"));
+ assert_eq!(s[1], format!("q 1 1 1 rg 0 0 300 200 re n Q {TEXT}"), "only the fill operator changed");
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "nothing hides the background now");
+ // The original stream object is untouched; the page points at a copy.
+ let Object::Stream(orig) = &*doc.get(ObjRef { num: 5, generation: 0 }) else { panic!("object 5 is the content") };
+ assert_eq!(String::from_utf8_lossy(&orig.decoded().unwrap()), content);
+ assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
+ assert_eq!(streams(&reopen(&doc)), original, "exactly the original content");
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill));
+}
+
+#[test]
+fn updating_the_background_replaces_the_backdrop_once_and_removing_still_restores() {
+ let mut doc = page(&[&format!("1 1 1 rg 0 0 300 200 re f 0 g {TEXT}")]);
+ let original = streams(&doc);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], ..Background::default() }, true).unwrap();
+ let s = streams(&doc);
+ assert_eq!(s.len(), 2, "one background, one copy: {s:?}");
+ assert!(s[1].contains("re n"));
+ remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
+ assert_eq!(streams(&doc), original);
+}
+
+#[test]
+fn a_backdrop_split_across_content_streams_is_replaced_in_the_piece_holding_the_operator() {
+ let mut doc = page(&["1 1 1 rg 0 0 300 200 re", &format!("f* {TEXT}")]);
+ let original = streams(&doc);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill));
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ let s = streams(&doc);
+ assert_eq!(s[1..], ["1 1 1 rg 0 0 300 200 re".to_string(), format!("n {TEXT}")], "{s:?}");
+ remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
+ assert_eq!(streams(&doc), original);
+}
+
+#[test]
+fn backdrops_writers_paint_are_recognised() {
+ for content in [
+ // Browsers: a flipped, scaled coordinate system and a page clip first.
+ "q 0.5 0 0 -0.5 0 200 cm 0 0 600 400 re W n 1 1 1 rg 0 0 600 400 re f Q",
+ // Larger than the page, in CMYK, through an opaque graphics state.
+ "/Full gs 0 0 0 0 k -10 -10 320 220 re f",
+ "/DeviceRGB cs 1 1 1 sc 0 0 300 200 re F",
+ "0.99 g 0 0 300 200 re f",
+ // Turned a quarter: still a rectangle.
+ "0 1 -1 0 300 0 cm 1 g 0 0 200 300 re f",
+ ] {
+ let doc = page(&[&format!("{content} 0 g {TEXT}")]);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{content}");
+ }
+}
+
+#[test]
+fn content_that_is_not_a_page_backdrop_is_left_alone() {
+ for content in [
+ // Not the whole page.
+ "1 g 0 0 300 150 re f",
+ "q 0 0 100 100 re W n 1 g 0 0 300 200 re f Q",
+ // Not white, or not opaque.
+ "0.5 g 0 0 300 200 re f",
+ "1 0 0 rg 0 0 300 200 re f",
+ "0 0 0 0.5 k 0 0 300 200 re f",
+ "/Half gs 1 g 0 0 300 200 re f",
+ "/Missing gs 1 g 0 0 300 200 re f",
+ "/Pattern cs /P0 scn 0 0 300 200 re f",
+ // The initial colour is black.
+ "0 0 300 200 re f",
+ // Something else is painted first.
+ "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re f",
+ "0 g 0 0 1 1 re f 1 g 0 0 300 200 re f",
+ "1 g 0 0 300 200 re S 1 g 0 0 300 200 re f",
+ // Not a single rectangle: a hole, a turned square, a fill-and-stroke.
+ "1 g 0 0 300 200 re 50 50 10 10 re f*",
+ "1 g 0 0 m 300 0 l 300 200 l 0 200 l h f",
+ "0.7 0.7 -0.7 0.7 150 -100 cm 1 g 0 0 400 400 re f",
+ "1 g 0 0 300 200 re B",
+ // Malformed.
+ "Q 1 g 0 0 300 200 re f",
+ "1 g 0 0 300 re f",
+ "1 g 0 0 1e400 200 re f",
+ ] {
+ let mut doc = page(&[&format!("{content} {TEXT}")]);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{content}");
+ let before = streams(&doc);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ assert_eq!(streams(&doc)[1..], before[..], "{content}: the page's content is unchanged");
+ }
+}
+
+#[test]
+fn a_scanned_page_is_reported_and_its_picture_kept() {
+ let mut doc = page(&[&format!("q 300 0 0 200 0 0 cm /Im0 Do Q {TEXT}")]);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Image));
+ let before = streams(&doc);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ assert_eq!(streams(&doc)[1..], before[..]);
+ // A small picture is content, not a backdrop.
+ let doc = page(&["q 30 0 0 20 0 0 cm /Im0 Do Q"]);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
+}
+
+#[test]
+fn replacing_can_be_turned_off() {
+ let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
+ let before = streams(&doc);
+ add_background(&mut doc, &[0], &Background { replace_backdrop: false, ..red() }, false).unwrap();
+ assert_eq!(streams(&doc)[1..], before[..]);
+}
+
+#[test]
+fn a_backdrop_copy_edited_since_is_kept_when_the_background_goes() {
+ let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ // Something else rewrites the copy (as Edit text does), keeping its dictionary.
+ let p = &pdfcraft_model::pages(&doc)[0];
+ let Some(Object::Array(list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") };
+ let Object::Ref(copy) = list[1] else { panic!("a reference") };
+ let Object::Stream(s) = (*doc.get(copy)).clone() else { panic!("a stream") };
+ let edited = "1 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET";
+ doc.set(copy, Object::Stream(Stream::flate(s.dict.clone(), edited.as_bytes())));
+ remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
+ assert_eq!(streams(&doc), [edited.to_string()], "the edit survives");
+}
+
+#[test]
+fn a_content_stream_shared_by_two_pages_is_copied_for_the_one_page() {
+ let content = format!("1 g 0 0 300 200 re f {TEXT}");
+ let mut doc = build(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
+ stream("", &content),
+ ]);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
+ assert_eq!(page_backdrop(&doc, 1).unwrap(), Some(Backdrop::Fill), "the other page keeps its backdrop");
+}
+
+#[test]
+fn pages_without_content_or_with_odd_contents_have_no_backdrop() {
+ let doc = build(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(),
+ "<< /Type /Page /Parent 2 0 R >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents [5 0 R 99 0 R] >>".into(),
+ "<< /Not /AStream >>".into(),
+ ]);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
+ assert_eq!(page_backdrop(&doc, 1).unwrap(), None);
+ assert!(matches!(page_backdrop(&doc, 7), Err(EditError::NoSuchPage(7))));
+}
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index 515f17d8da..a8ab1d51b5 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -150,11 +150,16 @@ pub struct Background {
pub source: Option,
/// Pictures: size relative to the page (1.0 fits the page).
pub scale: f64,
+ /// Replace a white backdrop the page paints itself, which would hide the background
+ /// ([`backdrop`]). Removing the background puts it back.
+ pub replace_backdrop: bool,
}
impl Default for Background {
+ /// A light yellow that still reads as a colour on screen (a near-white default looked as if
+ /// nothing had happened).
fn default() -> Self {
- Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 }
+ Self { color: [1.0, 0.95, 0.7], opacity: 1.0, source: None, scale: 1.0, replace_backdrop: true }
}
}
@@ -583,7 +588,9 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
Ok(())
}
-/// Add a solid-colour background behind the content of `pages`.
+/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a
+/// white backdrop a page paints itself is replaced first, so the background shows (see
+/// [`backdrop`]); a picture backdrop (a scan) still hides it.
pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
@@ -592,6 +599,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
}
let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 };
for &i in pages {
+ if bg.replace_backdrop {
+ backdrop::replace(doc, i)?;
+ }
let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc));
@@ -614,13 +624,17 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
}
/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on
-/// top of a page, the q/Q wrapper goes too, restoring the original content list.
+/// top of a page, the q/Q wrapper goes too, restoring the original content list. Removing
+/// backgrounds also puts back a backdrop [`add_background`] replaced, unless it was edited since.
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result {
let all = page_list(doc);
check(pages, all.len())?;
let mut removed = 0;
for &i in pages {
let page = &all[i];
+ if kind == MarkKind::Background {
+ backdrop::restore(doc, page)?;
+ }
let list = contents(doc, page)?;
let before = list.len();
let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
@@ -644,6 +658,8 @@ pub fn marks_present(doc: &Document) -> Vec {
added::scan_markers(doc).marks
}
+pub mod backdrop;
+pub use backdrop::{Backdrop, page_backdrop};
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 3432cd868e..0f81fb22de 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -63,7 +63,7 @@ pub use pdfcraft_forms::{
};
pub use pdfcraft_a11y as a11y;
-pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable};
+pub use pdfcraft_edit::{Backdrop, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
@@ -499,6 +499,11 @@ impl Document {
self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default()
}
+ /// The backdrop `page` (0-based) paints itself, which hides a background (Add Background).
+ pub fn page_backdrop(&self, page: usize) -> Option {
+ self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten())
+ }
+
/// Edit a PDF: the images `page` draws (0-based).
pub fn page_images(&self, page: usize) -> Vec {
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default()
diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs
index 4a60c40a6e..ed2490737c 100644
--- a/crates/ui-egui/src/marks_ui.rs
+++ b/crates/ui-egui/src/marks_ui.rs
@@ -3,7 +3,7 @@
//! preview of the current page. "Update" opens the same dialog in replace mode.
use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2};
-use pdfcraft_engine::{Background, Edit, HeaderFooter, MarkKind, Watermark};
+use pdfcraft_engine::{Backdrop, Background, DocId, Edit, HeaderFooter, MarkKind, Watermark};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
@@ -72,6 +72,63 @@ impl PageRange {
}
}
+/// How many pages a frame the Background dialog checks for a backdrop of their own, so a long
+/// document never stalls the dialog.
+const BACKDROP_PAGES_PER_FRAME: usize = 24;
+
+/// Which pages paint their own backdrop (it hides a background), for the document and edit
+/// generation they were checked in. Filled a few pages a frame.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct BackdropCheck {
+ key: Option<(DocId, u64)>,
+ /// Per page: `None` until checked.
+ pages: Vec>>,
+}
+
+/// What the check found among the pages a background goes on.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct BackdropCount {
+ /// Pages with a white backdrop that can be replaced.
+ pub fills: usize,
+ /// Pages covered by a picture (scans): the background stays hidden there.
+ pub images: usize,
+ /// Pages not checked yet.
+ pub pending: usize,
+}
+
+impl BackdropCheck {
+ /// Check up to [`BACKDROP_PAGES_PER_FRAME`] more of `pages` (0-based) and count them.
+ pub fn update(&mut self, doc: &pdfcraft_engine::Document, pages: &[usize]) -> BackdropCount {
+ let key = (doc.id, doc.edit_generation());
+ let count = doc.info.pages.len();
+ if self.key != Some(key) || self.pages.len() != count {
+ self.key = Some(key);
+ self.pages = vec![None; count];
+ }
+ let mut budget = BACKDROP_PAGES_PER_FRAME;
+ let mut n = BackdropCount::default();
+ for &p in pages {
+ let Some(slot) = self.pages.get_mut(p) else { continue };
+ if slot.is_none() && budget > 0 {
+ *slot = Some(doc.page_backdrop(p));
+ budget -= 1;
+ }
+ match slot {
+ None => n.pending += 1,
+ Some(Some(Backdrop::Fill)) => n.fills += 1,
+ Some(Some(Backdrop::Image)) => n.images += 1,
+ Some(None) => {}
+ }
+ }
+ n
+ }
+}
+
+/// "1 page" or "{n} pages", in a message.
+fn pages_phrase(n: usize, one: &str, many: &str) -> String {
+ if n == 1 { one.to_string() } else { crate::i18n::fmt(many, &[("n", &n.to_string())]) }
+}
+
#[derive(Clone, Debug, PartialEq)]
pub struct MarksDraft {
pub hf: HeaderFooter,
@@ -90,6 +147,8 @@ pub struct MarksDraft {
pub use_file: bool,
pub file: Option<(String, std::sync::Arc>)>,
pub file_page: usize,
+ /// Background: pages that paint their own backdrop.
+ pub backdrops: BackdropCheck,
}
impl Default for MarksDraft {
@@ -107,6 +166,7 @@ impl Default for MarksDraft {
use_file: false,
file: None,
file_page: 1,
+ backdrops: BackdropCheck::default(),
}
}
}
@@ -299,6 +359,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M
d.bg.opacity = pct / 100.0;
}
});
+ if let Some(doc) = app.session.get(id) {
+ backdrop_options(ui, t, d, doc, count);
+ }
}
}
ui.add_space(8.0);
@@ -364,6 +427,13 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M
}
MarkKind::Background => {
p.rect_filled(r, CornerRadius::ZERO, rgb32(bg.color).gamma_multiply(bg.opacity as f32));
+ // Schematic lines of text on top: the colour goes behind the content.
+ let (gap, inset) = (r.height() / 9.0, r.width() * 0.12);
+ for k in 1..8 {
+ let y = r.top() + gap * k as f32;
+ let end = if k % 3 == 0 { r.center().x } else { r.right() - inset };
+ p.line_segment([pos2(r.left() + inset, y), pos2(end, y)], Stroke::new((gap * 0.3).clamp(1.0, 3.0), Color32::from_gray(90)));
+ }
}
});
ui.vertical(|ui| {
@@ -477,6 +547,35 @@ fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) -> bool {
browse
}
+/// Pages that paint their own backdrop would hide the background: offer to replace a white one,
+/// and say where a picture (a scan) will still hide it.
+fn backdrop_options(ui: &mut egui::Ui, t: &Tokens, d: &mut MarksDraft, doc: &pdfcraft_engine::Document, count: usize) {
+ let n = d.backdrops.update(doc, &d.range.pages(count));
+ if n.pending > 0 {
+ ui.label(egui::RichText::new(tl!("Checking pages…")).small().color(t.text_faint));
+ ui.ctx().request_repaint();
+ }
+ if n.fills > 0 {
+ ui.add_space(4.0);
+ let label = pages_phrase(
+ n.fills,
+ tl!("This page paints its own white backdrop, which would hide the background. Replace it"),
+ tl!("{n} pages paint their own white backdrop, which would hide the background. Replace it"),
+ );
+ ui.add(widgets::checkbox(&mut d.bg.replace_backdrop, &label));
+ ui.label(egui::RichText::new(tl!("Removing the background puts the page's own backdrop back.")).small().color(t.text_faint));
+ }
+ if n.images > 0 {
+ ui.add_space(4.0);
+ let text = pages_phrase(
+ n.images,
+ tl!("A picture covers this page (a scan?), so the background won't show there."),
+ tl!("A picture covers {n} pages (scans?), so the background won't show there."),
+ );
+ ui.label(egui::RichText::new(text).small().color(crate::content_ui::UNDRAWABLE));
+ }
+}
+
/// The edit the dialog's OK makes.
pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit {
let pages = d.range.pages(count);
diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs
index 0a0e45b152..3a9864ea47 100644
--- a/crates/ui-egui/tests/ui_group/marks.rs
+++ b/crates/ui-egui/tests/ui_group/marks.rs
@@ -96,3 +96,70 @@ fn backgrounds_and_watermarks_from_a_file() {
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background"));
}
+
+/// Three pages: a white backdrop of its own (as browsers write), a page-sized picture (a scan)
+/// and bare paper.
+fn backdrop_fixture() -> Vec {
+ let stream = |data: &str| format!("<< /Length {} >> stream\n{data}\nendstream", data.len());
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 9 0 R >> >> >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 8 0 R >>".into(),
+ stream("q 1 1 1 rg 0 0 300 400 re f Q 0 g 20 20 50 50 re f"),
+ stream("q 300 0 0 400 0 0 cm /Im0 Do Q"),
+ stream("0 g 20 20 50 50 re f"),
+ "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >> stream\n\u{80}\nendstream"
+ .into(),
+ ];
+ let mut out = String::from("%PDF-1.7\n");
+ for (i, o) in objs.iter().enumerate() {
+ out.push_str(&format!("{} 0 obj {o} endobj\n", i + 1));
+ }
+ out.push_str("trailer << /Root 1 0 R >>\n%%EOF");
+ out.into_bytes()
+}
+
+#[test]
+fn the_background_dialog_offers_to_replace_a_white_backdrop_and_warns_about_scans() {
+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app.open_bytes("doc.pdf", None, backdrop_fixture()).unwrap();
+ app
+ });
+ h.run_steps(4);
+ let backdrop = |h: &Harness<'static, PdfCraftApp>, page| {
+ let s = h.state();
+ s.session.get(s.views[0].id).unwrap().page_backdrop(page)
+ };
+ assert_eq!(backdrop(&h, 0), Some(pdfcraft_engine::Backdrop::Fill));
+ h.state_mut().execute("edit.background");
+ h.run_steps(3);
+ let replace = "This page paints its own white backdrop, which would hide the background. Replace it";
+ h.get_by_label("A picture covers this page (a scan?), so the background won't show there.");
+ assert!(h.state().marks_draft.bg.replace_backdrop, "on by default");
+ // Left unticked, the backdrop stays and still hides the background.
+ h.get_by_label(replace).click();
+ h.run_steps(2);
+ h.get_by_label("OK").click();
+ h.run_steps(3);
+ assert_eq!(backdrop(&h, 0), Some(pdfcraft_engine::Backdrop::Fill));
+ assert!(h.state_mut().execute("edit.undo"));
+ h.run_steps(2);
+ // Ticked, it is replaced; the scan is reported, not touched.
+ h.state_mut().execute("edit.background");
+ h.run_steps(3);
+ h.get_by_label(replace).click();
+ h.run_steps(2);
+ assert!(h.state().marks_draft.bg.replace_backdrop);
+ h.get_by_label("OK").click();
+ h.run_steps(3);
+ assert_eq!(backdrop(&h, 0), None);
+ assert_eq!(backdrop(&h, 1), Some(pdfcraft_engine::Backdrop::Image));
+ // Nothing left to replace: the offer is gone.
+ h.state_mut().execute("edit.background.update");
+ h.run_steps(3);
+ assert!(h.query_by_label(replace).is_none());
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index c3d319f94f..e6b3dee939 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -2495,9 +2495,9 @@ 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"
+tools = ["doc_background", "doc_backdrops"]
+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", "crates/automation/tests/backdrop.rs::a_background_shows_on_a_page_that_paints_its_own_white_backdrop", "crates/ui-egui/tests/ui_group/marks.rs::the_background_dialog_offers_to_replace_a_white_backdrop_and_warns_about_scans", "crates/edit/src/backdrop_tests.rs::content_that_is_not_a_page_backdrop_is_left_alone"]
+notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page. A page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) used to hide the background entirely; the dialog now offers to replace that fill (on by default; Remove puts it back unless the content was edited since). Our own choice: Acrobat's behaviour on such pages has not been observed. Still hidden: scanned pages (warned), backdrops drawn inside a form XObject, by a non-rectangular path, or in a named colour space; the dialog preview is a schematic, not the page"
[[feature]]
id = "edit.background-update-remove"
@@ -2508,8 +2508,8 @@ 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 = ""
+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", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::a_backdrop_copy_edited_since_is_kept_when_the_background_goes"]
+notes = "Remove also restores a page backdrop Add replaced"
[[feature]]
id = "edit.decoration-profiles"
From b5d34d439f9ef1f2393c5102fe24edd88a1afda8 Mon Sep 17 00:00:00 2001
From: alehel
Date: Fri, 16 Oct 2026 11:14:10 +0200
Subject: [PATCH 013/270] fix(ui): translate the Add Background backdrop
messages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The six new dialog strings (the backdrop offer, its note, the scan
warning and "Checking pages…") were missing from every catalogue, which
the *_covers_ui_literals tests require. Languages with several plural
forms use the "label: {n}" pattern so any count reads correctly.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/i18n/ar.tsv | 6 ++++++
crates/ui-egui/src/i18n/bg.tsv | 6 ++++++
crates/ui-egui/src/i18n/cs.tsv | 6 ++++++
crates/ui-egui/src/i18n/de.tsv | 6 ++++++
crates/ui-egui/src/i18n/el.tsv | 6 ++++++
crates/ui-egui/src/i18n/es.tsv | 6 ++++++
crates/ui-egui/src/i18n/fr.tsv | 6 ++++++
crates/ui-egui/src/i18n/hu.tsv | 6 ++++++
crates/ui-egui/src/i18n/it.tsv | 6 ++++++
crates/ui-egui/src/i18n/ja.tsv | 6 ++++++
crates/ui-egui/src/i18n/kk.tsv | 6 ++++++
crates/ui-egui/src/i18n/lt.tsv | 6 ++++++
crates/ui-egui/src/i18n/pl.tsv | 6 ++++++
crates/ui-egui/src/i18n/pt-br.tsv | 6 ++++++
crates/ui-egui/src/i18n/ru.tsv | 6 ++++++
crates/ui-egui/src/i18n/sl.tsv | 6 ++++++
crates/ui-egui/src/i18n/te.tsv | 6 ++++++
crates/ui-egui/src/i18n/tr.tsv | 6 ++++++
crates/ui-egui/src/i18n/uk.tsv | 6 ++++++
crates/ui-egui/src/i18n/vi.tsv | 6 ++++++
crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++++++
crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++++++
22 files changed, 132 insertions(+)
diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv
index 5a0e6e9c96..abea3aae73 100644
--- a/crates/ui-egui/src/i18n/ar.tsv
+++ b/crates/ui-egui/src/i18n/ar.tsv
@@ -822,6 +822,12 @@
Applies to 1 page يُطبَّق على صفحة واحدة
Applies to {n} pages يُطبَّق على {n} صفحات
Replaces the existing one on those pages. يستبدل الموجود على تلك الصفحات.
+ Checking pages… جارٍ فحص الصفحات…
+ This page paints its own white backdrop, which would hide the background. Replace it ترسم هذه الصفحة تعبئة بيضاء خاصة بها ستُخفي الخلفية. استبدالها
+ {n} pages paint their own white backdrop, which would hide the background. Replace it عدد الصفحات التي ترسم تعبئة بيضاء خاصة بها ستُخفي الخلفية: {n}. استبدالها
+ Removing the background puts the page's own backdrop back. إزالة الخلفية تعيد التعبئة البيضاء الخاصة بالصفحة.
+ A picture covers this page (a scan?), so the background won't show there. تغطي صورة هذه الصفحة (مسح ضوئي؟)، لذا لن تظهر الخلفية فيها.
+ A picture covers {n} pages (scans?), so the background won't show there. عدد الصفحات التي تغطيها صور (مسح ضوئي؟): {n}؛ لن تظهر الخلفية فيها.
PDF or image PDF أو صورة
No file chosen لم يُختر ملف
Page number رقم الصفحة
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 90791f4a86..e5f2eff8f4 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -496,6 +496,12 @@
Applies to 1 page Прилага се към 1 страница
Applies to {n} pages Прилага се към {n} страници
Replaces the existing one on those pages. Заменя съществуващия на тези страници.
+ Checking pages… Проверка на страниците…
+ This page paints its own white backdrop, which would hide the background. Replace it Тази страница има собствено бяло запълване, което би скрило фона. Замяна
+ {n} pages paint their own white backdrop, which would hide the background. Replace it Страници със собствено бяло запълване, което би скрило фона: {n}. Замяна
+ Removing the background puts the page's own backdrop back. Премахването на фона връща собственото бяло запълване на страницата.
+ A picture covers this page (a scan?), so the background won't show there. Изображение покрива тази страница (сканиране?), затова фонът няма да се вижда там.
+ A picture covers {n} pages (scans?), so the background won't show there. Страници, покрити с изображения (сканирания?): {n}; фонът няма да се вижда там.
PDF or image PDF или изображение
No file chosen Не е избран файл
Page number Номер на страница
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 4bbb9a5e77..a76c53166a 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -896,6 +896,12 @@ key Space Mezerník
Applies to 1 page Týká se 1 stránky
Applies to {n} pages Počet dotčených stránek: {n}
Replaces the existing one on those pages. Nahradí stávající na těchto stránkách.
+ Checking pages… Kontrola stránek…
+ This page paints its own white backdrop, which would hide the background. Replace it Tato stránka má vlastní bílou výplň, která by zakryla pozadí. Nahradit ji
+ {n} pages paint their own white backdrop, which would hide the background. Replace it Stránky s vlastní bílou výplní, která by zakryla pozadí: {n}. Nahradit ji
+ Removing the background puts the page's own backdrop back. Odebráním pozadí se vrátí vlastní bílá výplň stránky.
+ A picture covers this page (a scan?), so the background won't show there. Tuto stránku pokrývá obrázek (sken?), takže pozadí na ní nebude vidět.
+ A picture covers {n} pages (scans?), so the background won't show there. Stránky pokryté obrázky (skeny?): {n}; pozadí na nich nebude vidět.
PDF or image PDF nebo obrázek
No file chosen Není vybrán žádný soubor
Page number Číslo stránky
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 7965435257..9ec1649f0c 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -800,6 +800,12 @@
Applies to 1 page Gilt für 1 Seite
Applies to {n} pages Gilt für {n} Seiten
Replaces the existing one on those pages. Ersetzt das Vorhandene auf diesen Seiten.
+ Checking pages… Seiten werden geprüft…
+ This page paints its own white backdrop, which would hide the background. Replace it Diese Seite hat eine eigene weiße Fläche, die den Hintergrund verdecken würde. Ersetzen
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} Seiten haben eine eigene weiße Fläche, die den Hintergrund verdecken würde. Ersetzen
+ Removing the background puts the page's own backdrop back. Wird der Hintergrund entfernt, kehrt die eigene weiße Fläche der Seite zurück.
+ A picture covers this page (a scan?), so the background won't show there. Ein Bild bedeckt diese Seite (ein Scan?), daher ist der Hintergrund dort nicht zu sehen.
+ A picture covers {n} pages (scans?), so the background won't show there. Bilder bedecken {n} Seiten (Scans?), daher ist der Hintergrund dort nicht zu sehen.
PDF or image PDF oder Bild
No file chosen Keine Datei ausgewählt
Page number Seitenzahl
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 57de6eef07..509adcb495 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -432,6 +432,12 @@
Applies to 1 page Εφαρμόζεται σε 1 σελίδα
Applies to {n} pages Εφαρμόζεται σε {n} σελίδες
Replaces the existing one on those pages. Αντικαθιστά το υπάρχον στοιχείο στις συγκεκριμένες σελίδες.
+ Checking pages… Έλεγχος σελίδων…
+ This page paints its own white backdrop, which would hide the background. Replace it Αυτή η σελίδα έχει δική της λευκή γέμιση, που θα έκρυβε το φόντο. Αντικατάσταση
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} σελίδες έχουν δική τους λευκή γέμιση, που θα έκρυβε το φόντο. Αντικατάσταση
+ Removing the background puts the page's own backdrop back. Η αφαίρεση του φόντου επαναφέρει τη λευκή γέμιση της σελίδας.
+ A picture covers this page (a scan?), so the background won't show there. Μια εικόνα καλύπτει αυτή τη σελίδα (σάρωση;), οπότε το φόντο δεν θα φαίνεται εκεί.
+ A picture covers {n} pages (scans?), so the background won't show there. Εικόνες καλύπτουν {n} σελίδες (σαρώσεις;), οπότε το φόντο δεν θα φαίνεται εκεί.
PDF or image PDF ή εικόνα
No file chosen Δεν έχει επιλεγεί αρχείο
Scale relative to target page Κλίμακα ως προς τη σελίδα προορισμού
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index e4eb634468..b3cd598fcf 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -816,6 +816,12 @@
Applies to 1 page Se aplica a 1 página
Applies to {n} pages Se aplica a {n} páginas
Replaces the existing one on those pages. Reemplaza al existente en esas páginas.
+ Checking pages… Comprobando páginas…
+ This page paints its own white backdrop, which would hide the background. Replace it Esta página tiene su propio relleno blanco, que ocultaría el fondo. Reemplazarlo
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} páginas tienen su propio relleno blanco, que ocultaría el fondo. Reemplazarlo
+ Removing the background puts the page's own backdrop back. Al quitar el fondo, se restaura el relleno blanco propio de la página.
+ A picture covers this page (a scan?), so the background won't show there. Una imagen cubre esta página (¿un escaneo?), así que el fondo no se verá ahí.
+ A picture covers {n} pages (scans?), so the background won't show there. Hay imágenes que cubren {n} páginas (¿escaneos?), así que el fondo no se verá ahí.
PDF or image PDF o imagen
No file chosen Ningún archivo elegido
Page number Número de página
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 62acea1da8..9a693fc49c 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -796,6 +796,12 @@
Applies to 1 page S'applique à 1 page
Applies to {n} pages S'applique à {n} pages
Replaces the existing one on those pages. Remplace celui qui existe déjà sur ces pages.
+ Checking pages… Vérification des pages…
+ This page paints its own white backdrop, which would hide the background. Replace it Cette page a son propre remplissage blanc, qui masquerait l'arrière-plan. Le remplacer
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} pages ont leur propre remplissage blanc, qui masquerait l'arrière-plan. Le remplacer
+ Removing the background puts the page's own backdrop back. Supprimer l'arrière-plan rétablit le remplissage blanc propre à la page.
+ A picture covers this page (a scan?), so the background won't show there. Une image couvre cette page (une numérisation ?), l'arrière-plan n'y sera donc pas visible.
+ A picture covers {n} pages (scans?), so the background won't show there. Des images couvrent {n} pages (des numérisations ?), l'arrière-plan n'y sera donc pas visible.
PDF or image PDF ou image
No file chosen Aucun fichier choisi
Page number Numéro de page
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 2238e1ade5..3eaf795525 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -793,6 +793,12 @@
Applies to 1 page 1 oldalra vonatkozik
Applies to {n} pages {n} oldalra vonatkozik
Replaces the existing one on those pages. Lecseréli a meglévőt ezeken az oldalakon.
+ Checking pages… Oldalak ellenőrzése…
+ This page paints its own white backdrop, which would hide the background. Replace it Ennek az oldalnak saját fehér kitöltése van, amely eltakarná a hátteret. Csere
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} oldalnak saját fehér kitöltése van, amely eltakarná a hátteret. Csere
+ Removing the background puts the page's own backdrop back. A háttér eltávolítása visszaállítja az oldal saját fehér kitöltését.
+ A picture covers this page (a scan?), so the background won't show there. Egy kép fedi le ezt az oldalt (szkennelt?), így ott nem látszik a háttér.
+ A picture covers {n} pages (scans?), so the background won't show there. Képek fednek le {n} oldalt (szkenneltek?), így ott nem látszik a háttér.
PDF or image PDF vagy kép
No file chosen Nincs fájl kiválasztva
Page number Oldalszám
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 3fa2263a0b..4f52f86e71 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -819,6 +819,12 @@
Applies to 1 page Si applica a 1 pagina
Applies to {n} pages Si applica a {n} pagine
Replaces the existing one on those pages. Sostituisce l'elemento esistente su quelle pagine.
+ Checking pages… Verifica delle pagine…
+ This page paints its own white backdrop, which would hide the background. Replace it Questa pagina ha un proprio riempimento bianco, che nasconderebbe lo sfondo. Sostituiscilo
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} pagine hanno un proprio riempimento bianco, che nasconderebbe lo sfondo. Sostituiscilo
+ Removing the background puts the page's own backdrop back. Rimuovendo lo sfondo, il riempimento bianco della pagina viene ripristinato.
+ A picture covers this page (a scan?), so the background won't show there. Un'immagine copre questa pagina (una scansione?), quindi lì lo sfondo non sarà visibile.
+ A picture covers {n} pages (scans?), so the background won't show there. Delle immagini coprono {n} pagine (scansioni?), quindi lì lo sfondo non sarà visibile.
PDF or image PDF o immagine
No file chosen Nessun file scelto
Page number Numero di pagina
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index ee9439e311..1abf8b00f3 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -823,6 +823,12 @@
Applies to 1 page 1 ページに適用
Applies to {n} pages {n} ページに適用
Replaces the existing one on those pages. 対象ページの既存の項目を置き換えます。
+ Checking pages… ページを確認しています…
+ This page paints its own white backdrop, which would hide the background. Replace it このページには独自の白い塗りがあり、背景が隠れてしまいます。置き換える
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} ページに独自の白い塗りがあり、背景が隠れてしまいます。置き換える
+ Removing the background puts the page's own backdrop back. 背景を削除すると、ページ独自の白い塗りが元に戻ります。
+ A picture covers this page (a scan?), so the background won't show there. このページは画像(スキャン?)で覆われているため、背景は表示されません。
+ A picture covers {n} pages (scans?), so the background won't show there. {n} ページが画像(スキャン?)で覆われているため、そこでは背景は表示されません。
PDF or image PDF または画像
No file chosen ファイルが選択されていません
Page number ページ番号
diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv
index 827165ae3a..3e5d693d52 100644
--- a/crates/ui-egui/src/i18n/kk.tsv
+++ b/crates/ui-egui/src/i18n/kk.tsv
@@ -778,6 +778,12 @@
Applies to 1 page 1 бетке қолданылады
Applies to {n} pages {n} бетке қолданылады
Replaces the existing one on those pages. Осы беттерде бар элементті ауыстырады.
+ Checking pages… Беттер тексерілуде…
+ This page paints its own white backdrop, which would hide the background. Replace it Бұл беттің өз ақ толтыруы бар, ол фонды жасырады. Ауыстыру
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} беттің өз ақ толтыруы бар, ол фонды жасырады. Ауыстыру
+ Removing the background puts the page's own backdrop back. Фонды жою беттің өз ақ толтыруын қайтарады.
+ A picture covers this page (a scan?), so the background won't show there. Бұл бетті сурет жауып тұр (сканер ме?), сондықтан онда фон көрінбейді.
+ A picture covers {n} pages (scans?), so the background won't show there. {n} бетті суреттер жауып тұр (сканер ме?), сондықтан онда фон көрінбейді.
PDF or image PDF немесе сурет
No file chosen Файл таңдалмады
Page number Бет нөмірі
diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv
index 56a785cd3e..09fd3a65f9 100644
--- a/crates/ui-egui/src/i18n/lt.tsv
+++ b/crates/ui-egui/src/i18n/lt.tsv
@@ -958,6 +958,12 @@
Replacement text Pakaitinis tekstas
Replacement: pages of {name} Pakaitinis dokumentas: {name} puslapiai
Replaces the existing one on those pages. Tuose puslapiuose pakeičia esamą.
+ Checking pages… Tikrinami puslapiai…
+ This page paints its own white backdrop, which would hide the background. Replace it Šis puslapis turi savo baltą užpildą, kuris paslėptų foną. Pakeisti
+ {n} pages paint their own white backdrop, which would hide the background. Replace it Puslapiai su savo baltu užpildu, kuris paslėptų foną: {n}. Pakeisti
+ Removing the background puts the page's own backdrop back. Pašalinus foną, grąžinamas paties puslapio baltas užpildas.
+ A picture covers this page (a scan?), so the background won't show there. Šį puslapį dengia paveikslėlis (nuskaitytas?), todėl fonas ten nesimatys.
+ A picture covers {n} pages (scans?), so the background won't show there. Paveikslėliais padengti puslapiai (nuskaityti?): {n}; fonas ten nesimatys.
Report Ataskaita
Report… Ataskaita…
Requires user to enter a password for: Naudotojas turi įvesti slaptažodį, kad galėtų:
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 3dbb247056..6d55eed026 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -814,6 +814,12 @@
Applies to 1 page Dotyczy 1 strony
Applies to {n} pages Liczba objętych stron: {n}
Replaces the existing one on those pages. Zastępuje istniejący element na tych stronach.
+ Checking pages… Sprawdzanie stron…
+ This page paints its own white backdrop, which would hide the background. Replace it Ta strona ma własne białe wypełnienie, które zasłoniłoby tło. Zastąp
+ {n} pages paint their own white backdrop, which would hide the background. Replace it Strony z własnym białym wypełnieniem, które zasłoniłoby tło: {n}. Zastąp
+ Removing the background puts the page's own backdrop back. Usunięcie tła przywraca własne białe wypełnienie strony.
+ A picture covers this page (a scan?), so the background won't show there. Tę stronę zakrywa obraz (skan?), więc tło nie będzie tam widoczne.
+ A picture covers {n} pages (scans?), so the background won't show there. Strony zakryte obrazami (skany?): {n}; tło nie będzie tam widoczne.
PDF or image PDF lub obraz
No file chosen Nie wybrano pliku
Page number Numer strony
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 8b3baa2d39..ca6f416885 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -451,6 +451,12 @@
Applies to 1 page Aplica-se a 1 página
Applies to {n} pages Aplica-se a {n} páginas
Replaces the existing one on those pages. Substitui o existente nessas páginas.
+ Checking pages… Verificando páginas…
+ This page paints its own white backdrop, which would hide the background. Replace it Esta página tem um preenchimento branco próprio, que esconderia o plano de fundo. Substituí-lo
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} páginas têm um preenchimento branco próprio, que esconderia o plano de fundo. Substituí-lo
+ Removing the background puts the page's own backdrop back. Remover o plano de fundo restaura o preenchimento branco da página.
+ A picture covers this page (a scan?), so the background won't show there. Uma imagem cobre esta página (uma digitalização?), então o plano de fundo não aparecerá nela.
+ A picture covers {n} pages (scans?), so the background won't show there. Imagens cobrem {n} páginas (digitalizações?), então o plano de fundo não aparecerá nelas.
PDF or image PDF ou imagem
No file chosen Nenhum arquivo escolhido
Page number Número da página
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index d0f8fc239c..92f80a3fbe 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -816,6 +816,12 @@
Applies to 1 page Применяется к 1 странице
Applies to {n} pages Применяется к {n} страницам
Replaces the existing one on those pages. Заменяет существующий на этих страницах.
+ Checking pages… Проверка страниц…
+ This page paints its own white backdrop, which would hide the background. Replace it На этой странице есть собственная белая заливка, которая скроет фон. Заменить
+ {n} pages paint their own white backdrop, which would hide the background. Replace it Страницы с собственной белой заливкой, которая скроет фон: {n}. Заменить
+ Removing the background puts the page's own backdrop back. При удалении фона собственная белая заливка страницы возвращается.
+ A picture covers this page (a scan?), so the background won't show there. Эту страницу закрывает изображение (скан?), поэтому фон там не будет виден.
+ A picture covers {n} pages (scans?), so the background won't show there. Страницы, закрытые изображениями (сканы?): {n}; фон там не будет виден.
PDF or image PDF или изображение
No file chosen Файл не выбран
Page number Номер страницы
diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv
index 92d9c9a6e3..eb6b9678c4 100644
--- a/crates/ui-egui/src/i18n/sl.tsv
+++ b/crates/ui-egui/src/i18n/sl.tsv
@@ -1485,6 +1485,12 @@
Replacement text Nadomestno besedilo
Replacement: pages of {name} Zamenjava: strani datoteke {name}
Replaces the existing one on those pages. Nadomesti obstoječe na teh straneh.
+ Checking pages… Preverjanje strani…
+ This page paints its own white backdrop, which would hide the background. Replace it Ta stran ima lastno belo polnilo, ki bi skrilo ozadje. Zamenjaj
+ {n} pages paint their own white backdrop, which would hide the background. Replace it Strani z lastnim belim polnilom, ki bi skrilo ozadje: {n}. Zamenjaj
+ Removing the background puts the page's own backdrop back. Odstranitev ozadja povrne lastno belo polnilo strani.
+ A picture covers this page (a scan?), so the background won't show there. To stran prekriva slika (skeniranje?), zato ozadje tam ne bo vidno.
+ A picture covers {n} pages (scans?), so the background won't show there. Strani, ki jih prekrivajo slike (skeniranja?): {n}; ozadje tam ne bo vidno.
Replacing images arrives on the web with image file pickers Zamenjava slik bo v spletni različici na voljo z izbirniki slikovnih datotek
Replacing images on page {p} arrives on the web with image pickers ({i}) Zamenjava slik na strani {p} bo v spletni različici na voljo z izbirniki slik ({i})
Reply Odgovori
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index 07b716eac1..9ec8e96bef 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -796,6 +796,12 @@ comment menu Edit సవరించు
Applies to 1 page 1 పేజీకి వర్తిస్తుంది
Applies to {n} pages {n} పేజీలకు వర్తిస్తుంది
Replaces the existing one on those pages. ఆ పేజీలలో ఇప్పటికే ఉన్నదాన్ని భర్తీ చేస్తుంది.
+ Checking pages… పేజీలను తనిఖీ చేస్తోంది…
+ This page paints its own white backdrop, which would hide the background. Replace it ఈ పేజీకి సొంత తెల్లని నింపు ఉంది, అది నేపథ్యాన్ని దాచేస్తుంది. భర్తీ చేయండి
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} పేజీలకు సొంత తెల్లని నింపు ఉంది, అది నేపథ్యాన్ని దాచేస్తుంది. భర్తీ చేయండి
+ Removing the background puts the page's own backdrop back. నేపథ్యాన్ని తీసివేస్తే పేజీ సొంత తెల్లని నింపు తిరిగి వస్తుంది.
+ A picture covers this page (a scan?), so the background won't show there. ఈ పేజీని ఒక చిత్రం కప్పుతోంది (స్కాన్?), కాబట్టి అక్కడ నేపథ్యం కనిపించదు.
+ A picture covers {n} pages (scans?), so the background won't show there. {n} పేజీలను చిత్రాలు కప్పుతున్నాయి (స్కాన్లు?), కాబట్టి అక్కడ నేపథ్యం కనిపించదు.
PDF or image PDF లేదా చిత్రం
No file chosen ఫైల్ ఏదీ ఎంచుకోలేదు
Page number పేజీ సంఖ్య
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 30e19f2132..e500ba7c17 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -777,6 +777,12 @@
Applies to 1 page 1 sayfaya uygulanır
Applies to {n} pages {n} sayfaya uygulanır
Replaces the existing one on those pages. Bu sayfalardaki mevcut olanın yerini alır.
+ Checking pages… Sayfalar denetleniyor…
+ This page paints its own white backdrop, which would hide the background. Replace it Bu sayfanın arka planı gizleyecek kendi beyaz dolgusu var. Değiştir
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} sayfanın arka planı gizleyecek kendi beyaz dolgusu var. Değiştir
+ Removing the background puts the page's own backdrop back. Arka planı kaldırmak, sayfanın kendi beyaz dolgusunu geri getirir.
+ A picture covers this page (a scan?), so the background won't show there. Bu sayfayı bir resim kaplıyor (tarama mı?), bu yüzden arka plan orada görünmez.
+ A picture covers {n} pages (scans?), so the background won't show there. {n} sayfayı resimler kaplıyor (tarama mı?), bu yüzden arka plan orada görünmez.
PDF or image PDF veya resim
No file chosen Dosya seçilmedi
Page number Sayfa numarası
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index c60799ef1f..76bf5b0791 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -820,6 +820,12 @@
Applies to 1 page Застосовується до 1 сторінки
Applies to {n} pages Застосовується до {n} сторінок
Replaces the existing one on those pages. Замінює наявний елемент на цих сторінках.
+ Checking pages… Перевірка сторінок…
+ This page paints its own white backdrop, which would hide the background. Replace it Ця сторінка має власну білу заливку, яка приховає тло. Замінити
+ {n} pages paint their own white backdrop, which would hide the background. Replace it Сторінки з власною білою заливкою, яка приховає тло: {n}. Замінити
+ Removing the background puts the page's own backdrop back. Вилучення тла повертає власну білу заливку сторінки.
+ A picture covers this page (a scan?), so the background won't show there. Цю сторінку закриває зображення (скан?), тому тло там не буде видно.
+ A picture covers {n} pages (scans?), so the background won't show there. Сторінки, закриті зображеннями (скани?): {n}; тло там не буде видно.
PDF or image PDF або зображення
No file chosen Файл не вибрано
Page number Номер сторінки
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index b072755236..5aabd0eb4f 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -953,6 +953,12 @@ theme Appearance Giao diện
Replacement text Văn bản được thay vào
Replacement: pages of {name} Thay thế: các trang của {name}
Replaces the existing one on those pages. Thay thế cái hiện có trên các trang đó.
+ Checking pages… Đang kiểm tra các trang…
+ This page paints its own white backdrop, which would hide the background. Replace it Trang này có lớp tô trắng riêng, sẽ che mất nền. Thay thế
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} trang có lớp tô trắng riêng, sẽ che mất nền. Thay thế
+ Removing the background puts the page's own backdrop back. Gỡ nền sẽ khôi phục lớp tô trắng riêng của trang.
+ A picture covers this page (a scan?), so the background won't show there. Một hình ảnh phủ kín trang này (bản quét?), nên nền sẽ không hiện ở đó.
+ A picture covers {n} pages (scans?), so the background won't show there. Hình ảnh phủ kín {n} trang (bản quét?), nên nền sẽ không hiện ở đó.
Report Báo cáo
Report… Báo cáo…
Requires user to enter a password for: Yêu cầu người dùng nhập mật khẩu cho:
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 2297d1269a..b3c2642e79 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -503,6 +503,12 @@
Applies to 1 page 应用于 1 页
Applies to {n} pages 应用于 {n} 页
Replaces the existing one on those pages. 替换这些页面上的现有内容。
+ Checking pages… 正在检查页面…
+ This page paints its own white backdrop, which would hide the background. Replace it 此页面有自己的白色填充,会遮住背景。替换它
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} 个页面有自己的白色填充,会遮住背景。替换它
+ Removing the background puts the page's own backdrop back. 移除背景后,页面自己的白色填充会恢复。
+ A picture covers this page (a scan?), so the background won't show there. 此页面被一张图片覆盖(扫描件?),因此那里不会显示背景。
+ A picture covers {n} pages (scans?), so the background won't show there. {n} 个页面被图片覆盖(扫描件?),因此那里不会显示背景。
PDF or image PDF 或图像
No file chosen 未选择文件
Page number 页码
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 2ddb33dd6d..611c6cf5ef 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -455,6 +455,12 @@
Applies to 1 page 套用於 1 頁
Applies to {n} pages 套用於 {n} 頁
Replaces the existing one on those pages. 替換這些頁面上的現有內容。
+ Checking pages… 正在檢查頁面…
+ This page paints its own white backdrop, which would hide the background. Replace it 此頁面有自己的白色填色,會遮住背景。取代它
+ {n} pages paint their own white backdrop, which would hide the background. Replace it {n} 個頁面有自己的白色填色,會遮住背景。取代它
+ Removing the background puts the page's own backdrop back. 移除背景後,頁面自己的白色填色會恢復。
+ A picture covers this page (a scan?), so the background won't show there. 此頁面被一張圖片覆蓋(掃描檔?),因此那裡不會顯示背景。
+ A picture covers {n} pages (scans?), so the background won't show there. {n} 個頁面被圖片覆蓋(掃描檔?),因此那裡不會顯示背景。
PDF or image PDF 或圖片
No file chosen 未選擇檔案
Page number 頁碼
From 93df949e3ef1c6f70868d3f60ee2f657a758a0c2 Mon Sep 17 00:00:00 2001
From: alehel
Date: Fri, 16 Oct 2026 11:40:07 +0200
Subject: [PATCH 014/270] fix(assets): refresh the catalogue hashes for the
backdrop translations
The 22 interface catalogues are registered assets; adding the Add
Background strings changed their SHA-256. Provenance is unchanged
(contributor-original translations).
Co-Authored-By: Claude Opus 5.5
---
ATTRIBUTION.toml | 44 ++++++++++++++++++++++----------------------
1 file changed, 22 insertions(+), 22 deletions(-)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index be9e2c448e..be2e770d56 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 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
+sha256 = "dd8a8b41a1a6de5d2f9f540d0c499366ccaa1ebfe62bb259f202dd4be03fc234"
# 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 = "dbfebf4e9b2e9ac31ecf72202c2098173e72ebfaf90e9fee8f0563a6734fe31f"
+sha256 = "392fa715e7a058a6d6f872a9381260e9e1598ef45b616edc71bc6b311d9c4a34"
[[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 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547"
+sha256 = "343b669decabca1e05a651b410b34427f2a28bb2dd22240a877bbb57a94d87af"
[[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 = "898a5240fa59cac42ef6b99cb60bcfca0094ac27370e1c8d020bd27936b56517"
+sha256 = "e56bb7c7ebe676b5cb606f0bff4102ca036a532a2c6afbcc74e8a221b2ae3382"
[[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 = "b4745c94797575529cd0e78c18c71fa2c0dfddfbf19b9c40b41dc2057c6f6543"
+sha256 = "9e3085c3a3d0c6114df4bd73d380d8efc4bb05ba07ad0285b4c54abd9a901b69"
[[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 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1"
+sha256 = "8a450837808cd319c82e39b4833623b64c3e50009beffd732d00b8eb88d32fbc"
[[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 = "1b739478a4f186a49900cce84edbe136206a53b46340322df4caf77bb3fb5b12"
+sha256 = "9c6c34ec7f372b3d3db2c88435b497eb4bbf2032859c1bfc3cfb12c47c3b36b6"
[[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 = "da3b71608ff7fe6e230b076207cee96ac01f66e068ca1be83af1ad56ac9c60eb"
+sha256 = "9d890f88003febb885e076df172872cfff76cde77cf22b1af57c570229dc4316"
[[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 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00"
+sha256 = "617bf25bb48c93a0578e5832f54420fc47b3a97ad31bf9ffd57556a1bdb4048b"
[[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 = "5e84916e3ad8670216360695689fe8fd624365a29886f66a3c655ba126892f5b"
+sha256 = "b90ef8f3a4b71e2aca3bc98f8420b25fbc6f86220b75f0c5f47b731551e506f8"
[[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 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7"
+sha256 = "77069128e2d00b03cedf460915fc00a0cc00880e3b1aecf6de769b06c218f71b"
[[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 = "084f4ea06f31c632ad90b0e87f66240ce5b5dc012ac7770987d44918ee66f452"
+sha256 = "2ee89f763e825609beb78b5f05f3a035cff4f01c81a0fb80dcefbbf209d19f98"
[[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 = "973cd90ec34c8a973d146a0e2c73c1d632e17357c3d87ccf10c59e64d12c1587"
+sha256 = "19844f166956ac49e6c4c09b57e2024e0d73c5c3e14ee8df994a1799bef9b648"
[[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 = "485f85d4053c9661ddda2ed9394a0644c77b489feb598d02f12ef209e11f2ee3"
+sha256 = "5b31891a0775d5e7a9cfb14a2f767b2fa17c9d948ce92b43cd371846a3d69322"
[[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 = "24524864022ab2abeff68b5979519eee4e92134d0c4053d40c741bc7f01c249c"
+sha256 = "e7b587ecf87095a1317bf37830ca60e75a16a0285dfa14e0361ddfd04b44800b"
[[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 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa"
+sha256 = "9413104f8543e8a03dccb014d62bd40a77645de5b279c55b3266c02e907afb89"
[[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 = "82678dbe730f56062037ea78de165b2c016b3fa670fb1cbfc7cd713eca900bac"
+sha256 = "f1490c97273e57b256e234a8a53766318f75f888a05144727efc226c7d67f62d"
[[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 = "304d661b27cdb5246f6b2974b4b5e78667f0928cda3588440be3d8f35f254679"
+sha256 = "2216666f2d355aed70b4c60e09bfe07d902036bbad52aee1e287e5f50a887903"
[[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 = "05e2b7d5401166d362ba055120fba9a1645d6ec324997cf670e19fafcb0c54cf"
+sha256 = "436777718e77c79ca54a16a629d725e7b480ad9fad5f25f7c249b446208797e4"
[[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 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa"
+sha256 = "d66b81e59728b669ed1a35e4773d0ef75b25f7143109e9a5fc7dd29e6a52ee25"
[[asset]]
@@ -3352,7 +3352,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 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6"
+sha256 = "a2dde60a333e8c75d8f07190149d28d3f210c0c8662e0493b4e67dd34789e453"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ 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 = "8f8a259264cda06eb6d9a9bf91dc7d873a8561213a9a9e7acbd32623bef40be0"
+sha256 = "73563d2d36a685ef23ed517eb635a654b1a5900abe7471db17b40af62b4cd223"
[[asset]]
From 22ec11cd28c1df31c5755c724c3cc4fdcdbc8e6f Mon Sep 17 00:00:00 2001
From: jonathanwxh-cell
Date: Sat, 17 Oct 2026 09:57:47 +0800
Subject: [PATCH 015/270] Keep lines with footnote references in paragraph
reading order (#2929)
---
crates/render/src/text.rs | 177 ++++++++++++++++++++++++++++++++++++--
1 file changed, 168 insertions(+), 9 deletions(-)
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index b767e28314..064a68d093 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -945,6 +945,32 @@ struct SegExtra {
more: Vec,
}
+/// Whether `(script_bbox, script_h)` is a superscript or subscript on `(host_bbox, host_h)`.
+fn is_script_on(host_bbox: &[f32; 4], host_h: f32, script_bbox: &[f32; 4], script_h: f32) -> bool {
+ let host_cy = (host_bbox[1] + host_bbox[3]) / 2.0;
+ let script_cy = (script_bbox[1] + script_bbox[3]) / 2.0;
+ let script_w = script_bbox[2] - script_bbox[0];
+ let host_w = host_bbox[2] - host_bbox[0];
+ let smaller = script_h >= host_h * 0.45 && script_w < host_h * 3.0 && (script_h < host_h * 0.9 || (script_h <= host_h && host_w > script_w));
+ if !smaller {
+ return false;
+ }
+ if script_cy < host_cy {
+ // Superscript: raised above the host baseline, with its bottom inside the host line.
+ host_cy - script_cy >= host_h * 0.12
+ && host_cy - script_cy < host_h * 0.7
+ && host_bbox[3] - script_bbox[3] >= host_h * 0.15
+ && script_bbox[3] > host_bbox[1] + host_h * 0.25
+ } else {
+ // Subscript: lowered below the host baseline, with its centre still inside the host line.
+ script_cy - host_cy >= host_h * 0.12
+ && script_cy - host_cy < host_h * 0.5
+ && script_bbox[3] > host_bbox[3]
+ && host_bbox[3] > script_bbox[1] + script_h * 0.5
+ && script_bbox[1] >= host_bbox[1] - script_h * 0.2
+ }
+}
+
impl Seg {
fn new(first: Run, bbox: [f32; 4], h: f32) -> Self {
Seg { first, extra: None, bbox, h }
@@ -958,9 +984,25 @@ impl Seg {
std::iter::once(self.first).chain(self.extra.iter().flat_map(|e| e.more.iter().copied()))
}
+ /// Extend `self`'s box and height with `(bbox, h)`. A smaller script (superscript or
+ /// subscript) widens the line horizontally without pulling its vertical baseline band up or
+ /// down; when a line begins with a script and continues with taller body text, the body text's
+ /// vertical band becomes the line's.
+ fn extend_box(&mut self, bbox: &[f32; 4], h: f32) {
+ if h > self.h * 1.15 || is_script_on(bbox, h, &self.bbox, self.h) {
+ self.bbox[1] = bbox[1];
+ self.bbox[3] = bbox[3];
+ } else if !(self.h > h * 1.15 || is_script_on(&self.bbox, self.h, bbox, h)) {
+ self.bbox[1] = self.bbox[1].min(bbox[1]);
+ self.bbox[3] = self.bbox[3].max(bbox[3]);
+ }
+ self.bbox[0] = self.bbox[0].min(bbox[0]);
+ self.bbox[2] = self.bbox[2].max(bbox[2]);
+ self.h = self.h.max(h);
+ }
+
fn merge(&mut self, other: Seg) {
- union(&mut self.bbox, &other.bbox);
- self.h = self.h.max(other.h);
+ self.extend_box(&other.bbox, other.h);
let extra = self.extra.get_or_insert_with(Default::default);
extra.more.push(other.first);
if let Some(o) = other.extra {
@@ -1177,8 +1219,10 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu
}
budget -= 1;
let (ra, rb) = (union_find(&mut sc.parent, ri), union_find(&mut sc.parent, rj));
+ let hj = height(j);
+ let script = is_script_on(&glyphs[i].rect, hi, &glyphs[j].rect, hj) || is_script_on(&glyphs[j].rect, hj, &glyphs[i].rect, hi);
let overlap = x1.min(glyphs[j].rect[2]) - x0(j);
- if ra != rb && overlap > RUN_COLLISION * hi.min(height(j)) {
+ if ra != rb && !script && overlap > RUN_COLLISION * hi.min(hj) {
sc.parent[ra] = rb;
collided = true;
}
@@ -1341,11 +1385,16 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
let mut furthest_progress = 0.0;
for i in 0..n {
let g = glyphs[i].rect;
+ let hi = height(i);
let cont = segs.last().is_some_and(|s| {
let Some(p) = s.first.end.checked_sub(1) else { return false };
- let ph = height(p).min(height(i));
+ let ph = height(p).min(hi);
+ let sh = s.h.min(hi);
let gap = g[0] - glyphs[p].rect[2];
- (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
+ let same_line = ((cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0)
+ || ((cy(i) - s.cy()).abs() < sh * 0.3 && (s.h / hi - 1.0).abs() < 0.35 && gap < sh * 3.0);
+ let on_seg = gap < s.h * 1.2 && (is_script_on(&s.bbox, s.h, &g, hi) || is_script_on(&g, hi, &s.bbox, s.h));
+ (same_line || on_seg) && g[0] > glyphs[p].rect[0] - sh * 2.0
});
let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress));
if cont
@@ -1353,11 +1402,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
&& let Some(s) = segs.last_mut()
{
s.first.end = i + 1;
- union(&mut s.bbox, &g);
- s.h = s.h.max(height(i));
+ s.extend_box(&g, hi);
furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs());
} else {
- segs.push(Seg::new(Run { start: i, end: i + 1 }, g, height(i)));
+ segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi));
run_start_x = g[0];
furthest_progress = 0.0;
}
@@ -1406,7 +1454,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
kept_runs
};
segs.truncate(retained);
- // Merge same-baseline segments that nearly touch.
+ // Merge same-baseline segments that nearly touch, then attach any remaining superscript or
+ // subscript segments to the closest host line segment they sit on.
let mut merged = true;
while merged {
merged = false;
@@ -1427,6 +1476,28 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
}
}
}
+ if !merged {
+ for b in 0..segs.len() {
+ let sb = &segs[b];
+ let best_host = (0..segs.len())
+ .filter(|&a| {
+ if a == b {
+ return false;
+ }
+ let sa = &segs[a];
+ let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]);
+ sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0] && gap < sa.h * 1.2 && is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h)
+ })
+ .min_by(|&a1, &a2| (segs[a1].cy() - sb.cy()).abs().total_cmp(&(segs[a2].cy() - sb.cy()).abs()));
+ if let Some(a) = best_host {
+ let sb = segs.remove(b);
+ let a = if b < a { a - 1 } else { a };
+ segs[a].merge(sb);
+ merged = true;
+ break;
+ }
+ }
+ }
}
// 2. Blocks.
@@ -2646,4 +2717,92 @@ mod tests {
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
}
+
+ /// Issue #2929: a footnote reference (superscript) on a line stays in that line instead of
+ /// splitting out as a raised segment that steals the block's tail and pushes the host line
+ /// after the paragraph.
+ #[test]
+ fn paragraph_selection_includes_lines_with_footnote_references() {
+ for inline in [true, false] {
+ let ops = if inline {
+ "BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \
+ 1 0 0 1 40 285 Tm (Second line with a note) Tj \
+ /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
+ /F1 12 Tf ( and more text) Tj \
+ 1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \
+ /F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj \
+ /F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj ET"
+ } else {
+ // Superscripts drawn in a separate pass after the body text.
+ "BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \
+ 1 0 0 1 40 285 Tm (Second line with a note) Tj \
+ 1 0 0 1 173 285 Tm (and more text) Tj \
+ 1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \
+ /F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj \
+ /F1 8 Tf 1 0 0 1 165 290.5 Tm (1) Tj \
+ /F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj ET"
+ };
+ let text = page_glyphs(ops);
+ let start = text.nearest(40.0, 95.0).unwrap();
+ let end = text.nearest(185.0, 128.0).unwrap();
+ assert_eq!(
+ text.text_of(start..end + 1),
+ "First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph",
+ "inline={inline}"
+ );
+ assert_eq!(text.line_rects(start..end + 1).len(), 3, "three selected lines, inline={inline}");
+ assert_eq!(
+ text.plain_text(),
+ "First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph\n1 Footnote at the bottom",
+ "inline={inline}"
+ );
+ }
+
+ // Superscript on the first line, multi-character bracketed reference `[12]` on the second
+ // line, and subscript on the third line (both inline and out-of-order).
+ for inline in [true, false] {
+ let ops = if inline {
+ "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \
+ /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
+ /F1 12 Tf ( in the first line) Tj \
+ 1 0 0 1 40 285 Tm (Middle citation) Tj \
+ /F1 8 Tf 5.5 Ts ([12]) Tj 0 Ts \
+ /F1 12 Tf ( continues here) Tj \
+ 1 0 0 1 40 270 Tm (Formula H) Tj \
+ /F1 8 Tf -3 Ts (2) Tj 0 Ts \
+ /F1 12 Tf (O in the third line) Tj ET"
+ } else {
+ "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \
+ 1 0 0 1 125 300 Tm (in the first line) Tj \
+ 1 0 0 1 40 285 Tm (Middle citation) Tj \
+ 1 0 0 1 135 285 Tm (continues here) Tj \
+ 1 0 0 1 40 270 Tm (Formula H) Tj \
+ 1 0 0 1 101 270 Tm (O in the third line) Tj \
+ /F1 8 Tf 1 0 0 1 117 305.5 Tm (1) Tj \
+ 1 0 0 1 117 290.5 Tm ([12]) Tj \
+ 1 0 0 1 96.5 267 Tm (2) Tj ET"
+ };
+ let text = page_glyphs(ops);
+ assert_eq!(
+ text.plain_text(),
+ "Opening claim1 in the first line\nMiddle citation[12] continues here\nFormula H2O in the third line",
+ "inline={inline}"
+ );
+ assert_eq!(text.line_rects(0..text.glyphs.len()).len(), 3, "inline={inline}");
+ }
+
+ // Justified wide gap (18pt = 1.5 * h) after an inline superscript stays on the same line,
+ // and an out-of-order superscript kerned slightly over punctuation does not break the host
+ // word via overprint collision.
+ let justified = page_glyphs(
+ "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Justified clause.) Tj \
+ /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
+ /F1 12 Tf 1 0 0 1 151 300 Tm (Next sentence on the same line) Tj \
+ 1 0 0 1 40 285 Tm (Kerned clause.) Tj \
+ 1 0 0 1 130 285 Tm (continues cleanly) Tj \
+ /F1 8 Tf 1 0 0 1 118 290.5 Tm (2) Tj ET",
+ );
+ assert_eq!(justified.plain_text(), "Justified clause.1 Next sentence on the same line\nKerned clause.2 continues cleanly");
+ assert_eq!(justified.line_rects(0..justified.glyphs.len()).len(), 2);
+ }
}
From 0b63f5a4ba6ef9b40703528953715cddc9e021d7 Mon Sep 17 00:00:00 2001
From: alehel
Date: Sat, 17 Oct 2026 05:43:46 +0200
Subject: [PATCH 016/270] fix(edit): check page backdrops with one page-tree
walk per batch
Review of #2815: `page_of` built every page to return one, and the Add
Background dialog checked 24 pages a frame that way, so a frame cost
24 page-tree walks (93 ms at 5,000 pages, 385 ms at 20,000).
- `page_backdrops(doc, pages)` walks the tree once for a batch; the
dialog checks one batch a frame and `doc_backdrops` one batch in all.
A page past the end answers None without hiding the others (the
viewer's page count can disagree with the page tree's).
- `scan` and `replace` take the page; `add_background` no longer walks
the tree twice more per page to replace a backdrop.
- Tests: 4,000 pages checked in one batch under a generous bound (17.6 s
with the old lookup), batch order and missing pages, and a background
over several pages sharing streams, replaced and restored.
Co-Authored-By: Claude Opus 5.5
---
crates/automation/src/lib.rs | 7 ++--
crates/edit/src/backdrop.rs | 52 ++++++++++++-----------
crates/edit/src/backdrop_tests.rs | 68 +++++++++++++++++++++++++++++++
crates/edit/src/lib.rs | 8 ++--
crates/engine/src/lib.rs | 9 ++++
crates/ui-egui/src/marks_ui.rs | 24 ++++++++---
6 files changed, 131 insertions(+), 37 deletions(-)
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 564dd8e882..ba4bf7ced8 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -1182,9 +1182,10 @@ impl Automation {
};
let doc = self.doc(a)?;
let found: Vec = pages
- .into_iter()
- .filter_map(|p| {
- let kind = match doc.page_backdrop(p)? {
+ .iter()
+ .zip(doc.page_backdrops(&pages))
+ .filter_map(|(p, found)| {
+ let kind = match found? {
pdfcraft_engine::Backdrop::Fill => "fill",
pdfcraft_engine::Backdrop::Image => "image",
};
diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs
index d15652f82b..967a8f9121 100644
--- a/crates/edit/src/backdrop.rs
+++ b/crates/edit/src/backdrop.rs
@@ -25,7 +25,8 @@ const SCAN_BYTES: usize = 64 * 1024;
const SCAN_OPS: usize = 4096;
/// How far (in points) a backdrop may fall short of the crop box and still count as covering it.
const COVER_EPS: f64 = 1.0;
-/// Components at least this close to white (per channel) count as white.
+/// Components at least this close to white (per channel) count as white: a near-white fill
+/// (`0.99 g`) is a backdrop too, and replacing it changes the page by at most 2%.
const WHITE: f64 = 0.98;
/// What paints a page's backdrop.
@@ -57,15 +58,26 @@ struct State {
opaque: bool,
}
-/// The backdrop `page` (0-based) paints, if any.
+/// The backdrop `page` (0-based) paints, if any. Finding the page walks the page tree, so to
+/// check many pages use [`page_backdrops`], which walks it once.
pub fn page_backdrop(doc: &Document, page: usize) -> Result, EditError> {
- Ok(scan(doc, page)?.map(|(b, _)| b))
+ let all = pdfcraft_model::pages(doc);
+ let p = all.get(page).ok_or(EditError::NoSuchPage(page))?;
+ Ok(scan(doc, p).map(|(b, _)| b))
+}
+
+/// The backdrop each of `pages` (0-based) paints, in the same order, walking the page tree once.
+/// A page past the end has none, and doesn't hide what the others paint: a page count taken
+/// elsewhere (the viewer's) can disagree with the page tree's on a malformed document.
+pub fn page_backdrops(doc: &Document, pages: &[usize]) -> Vec > {
+ let all = pdfcraft_model::pages(doc);
+ pages.iter().map(|&p| all.get(p).and_then(|page| scan(doc, page)).map(|(b, _)| b)).collect()
}
/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it
-/// did. A picture backdrop is left alone.
-pub(crate) fn replace(doc: &mut Document, page: usize) -> Result {
- let Some((Backdrop::Fill, Some(found))) = scan(doc, page)? else { return Ok(false) };
+/// did; when it did, `page` no longer matches the document. A picture backdrop is left alone.
+pub(crate) fn replace(doc: &mut Document, page: &pdfcraft_model::Page) -> Result {
+ let Some((Backdrop::Fill, Some(found))) = scan(doc, page) else { return Ok(false) };
let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]);
let piece = found.pieces.piece_of(found.op.start);
let (Some(&(slot, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) };
@@ -80,11 +92,10 @@ pub(crate) fn replace(doc: &mut Document, page: usize) -> Result Option {
(copy.decoded().ok()? == expect).then_some(*original)
}
-fn page_of(doc: &Document, page: usize) -> Result {
- pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
-}
-
/// Find the backdrop, and for a fill, where it is.
-fn scan(doc: &Document, page: usize) -> Result)>, EditError> {
- let p = page_of(doc, page)?;
+fn scan(doc: &Document, p: &pdfcraft_model::Page) -> Option<(Backdrop, Option)> {
let crop = p.crop(doc);
- let list: Vec = match p.dict.get(b"Contents") {
- None => return Ok(None),
- Some(c) => match &*doc.resolve(c) {
- Object::Array(a) => a.clone(),
- _ => vec![c.clone()],
- },
+ let c = p.dict.get(b"Contents")?;
+ let list: Vec = match &*doc.resolve(c) {
+ Object::Array(a) => a.clone(),
+ _ => vec![c.clone()],
};
// The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read
// only as far as a backdrop could be.
@@ -148,7 +152,7 @@ fn scan(doc: &Document, page: usize) -> Result)>
if total >= SCAN_BYTES {
break;
}
- let Object::Ref(r) = o else { return Ok(None) };
+ let Object::Ref(r) = o else { return None };
let obj = doc.get(*r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.get(b"PCMark").is_some() {
@@ -164,10 +168,10 @@ fn scan(doc: &Document, page: usize) -> Result )>
let parsed = pdfcraft_content::parse(head);
let res = resources(doc, &p.dict);
let found = walk(doc, &res, crop, &parsed.ops);
- Ok(found.map(|(b, op)| match (b, op) {
+ found.map(|(b, op)| match (b, op) {
(Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, op })),
_ => (b, None),
- }))
+ })
}
fn resources(doc: &Document, page: &Dict) -> Dict {
diff --git a/crates/edit/src/backdrop_tests.rs b/crates/edit/src/backdrop_tests.rs
index 88f058b4f5..5dc1d80e41 100644
--- a/crates/edit/src/backdrop_tests.rs
+++ b/crates/edit/src/backdrop_tests.rs
@@ -231,3 +231,71 @@ fn pages_without_content_or_with_odd_contents_have_no_backdrop() {
assert_eq!(page_backdrop(&doc, 1).unwrap(), None);
assert!(matches!(page_backdrop(&doc, 7), Err(EditError::NoSuchPage(7))));
}
+
+/// `pages` 300 × 200 pages in a flat tree; even pages paint a white backdrop, odd ones don't.
+fn many_pages(pages: usize) -> Document {
+ let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 5)).collect();
+ let mut objs = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 200] >>", kids.join(" ")),
+ stream("", &format!("1 g 0 0 300 200 re f 0 g {TEXT}")),
+ stream("", TEXT),
+ ];
+ objs.extend((0..pages).map(|i| format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>", 3 + i % 2)));
+ build(&objs)
+}
+
+#[test]
+fn checking_many_pages_walks_the_page_tree_once() {
+ // The Add Background dialog checks every page of a long document. Looking each page up on
+ // its own walked the whole tree per page, so checking them all grew with the square of the
+ // page count (minutes at 20,000 pages); one walk keeps it in proportion.
+ const PAGES: usize = 4000;
+ let doc = many_pages(PAGES);
+ let all: Vec = (0..PAGES).collect();
+ let start = std::time::Instant::now();
+ let found = page_backdrops(&doc, &all);
+ let took = start.elapsed();
+ assert_eq!(found.len(), PAGES);
+ for (i, f) in found.iter().enumerate() {
+ assert_eq!(*f, (i % 2 == 0).then_some(Backdrop::Fill), "page {i}");
+ }
+ assert!(took < std::time::Duration::from_secs(5), "checking {PAGES} pages took {took:?}");
+}
+
+#[test]
+fn a_batch_answers_in_the_order_asked_and_a_missing_page_hides_nothing() {
+ let doc = many_pages(5);
+ assert_eq!(page_backdrops(&doc, &[3, 0, 0]), [None, Some(Backdrop::Fill), Some(Backdrop::Fill)]);
+ assert_eq!(page_backdrops(&doc, &[]), []);
+ // The viewer can count more pages than the page tree holds: the others still answer.
+ assert_eq!(page_backdrops(&doc, &[2, 5, usize::MAX]), [Some(Backdrop::Fill), None, None]);
+ assert_eq!(page_backdrop(&doc, 4).unwrap(), Some(Backdrop::Fill));
+ assert!(matches!(page_backdrop(&doc, 5), Err(EditError::NoSuchPage(5))));
+}
+
+#[test]
+fn a_background_over_many_pages_replaces_each_backdrop_and_removing_restores_them() {
+ // Pages share their content streams, so each replaced page gets its own copy and the rest
+ // are checked against the document as it is after the earlier ones.
+ let mut doc = many_pages(4);
+ let all = [0, 1, 2, 3];
+ let before: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect();
+ add_background(&mut doc, &all, &red(), true).unwrap();
+ assert_eq!(page_backdrops(&doc, &all), [None; 4]);
+ // Update: the backdrops are gone already, so nothing is copied again.
+ add_background(&mut doc, &all, &red(), true).unwrap();
+ assert_eq!(page_backdrops(&doc, &all), [None; 4]);
+ remove_marks(&mut doc, &all, MarkKind::Background).unwrap();
+ let after: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect();
+ let refs = |list: &[Object]| -> Vec> {
+ list.iter()
+ .map(|c| match &*doc.resolve(c) {
+ Object::Array(a) => a.clone(),
+ _ => vec![c.clone()],
+ })
+ .collect()
+ };
+ assert_eq!(refs(&after), refs(&before), "every page points at its original stream again");
+ assert_eq!(page_backdrops(&doc, &all), [Some(Backdrop::Fill), None, Some(Backdrop::Fill), None]);
+}
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index a8ab1d51b5..802a258eb7 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -599,10 +599,10 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
}
let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 };
for &i in pages {
- if bg.replace_backdrop {
- backdrop::replace(doc, i)?;
+ let mut page = page_list(doc)[i].clone();
+ if bg.replace_backdrop && backdrop::replace(doc, &page)? {
+ page = page_list(doc)[i].clone();
}
- let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc));
match &bg.source {
@@ -659,7 +659,7 @@ pub fn marks_present(doc: &Document) -> Vec {
}
pub mod backdrop;
-pub use backdrop::{Backdrop, page_backdrop};
+pub use backdrop::{Backdrop, page_backdrop, page_backdrops};
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 0f81fb22de..0967f56524 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -504,6 +504,15 @@ impl Document {
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten())
}
+ /// [`Self::page_backdrop`] for each of `pages`, in order, walking the page tree once: the
+ /// way to check many pages. A page out of range has none.
+ pub fn page_backdrops(&self, pages: &[usize]) -> Vec> {
+ match &self.editor {
+ Some(e) => pdfcraft_edit::page_backdrops(&e.cos, pages),
+ None => vec![None; pages.len()],
+ }
+ }
+
/// Edit a PDF: the images `page` draws (0-based).
pub fn page_images(&self, page: usize) -> Vec {
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default()
diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs
index ed2490737c..c838f7861e 100644
--- a/crates/ui-egui/src/marks_ui.rs
+++ b/crates/ui-egui/src/marks_ui.rs
@@ -105,14 +105,26 @@ impl BackdropCheck {
self.key = Some(key);
self.pages = vec![None; count];
}
- let mut budget = BACKDROP_PAGES_PER_FRAME;
+ // One batch a frame: finding a page walks the page tree, so the batch shares one walk.
+ let mut batch = Vec::with_capacity(BACKDROP_PAGES_PER_FRAME);
+ for &p in pages {
+ if batch.len() >= BACKDROP_PAGES_PER_FRAME {
+ break;
+ }
+ if matches!(self.pages.get(p), Some(None)) && !batch.contains(&p) {
+ batch.push(p);
+ }
+ }
+ if !batch.is_empty() {
+ for (p, found) in batch.iter().zip(doc.page_backdrops(&batch)) {
+ if let Some(slot) = self.pages.get_mut(*p) {
+ *slot = Some(found);
+ }
+ }
+ }
let mut n = BackdropCount::default();
for &p in pages {
- let Some(slot) = self.pages.get_mut(p) else { continue };
- if slot.is_none() && budget > 0 {
- *slot = Some(doc.page_backdrop(p));
- budget -= 1;
- }
+ let Some(slot) = self.pages.get(p) else { continue };
match slot {
None => n.pending += 1,
Some(Some(Backdrop::Fill)) => n.fills += 1,
From 341e006b950dd38ecaf3f54d58e0cf18fd0d735f Mon Sep 17 00:00:00 2001
From: jonathanwxh-cell
Date: Sat, 17 Oct 2026 20:53:54 +0800
Subject: [PATCH 017/270] fix(render): batch out-of-order script merges and pin
leading-script extend_box (#2929)
---
crates/render/src/text.rs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index 064a68d093..b2a2d914c5 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -1477,7 +1477,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
}
}
if !merged {
- for b in 0..segs.len() {
+ let mut b = 0;
+ while b < segs.len() {
let sb = &segs[b];
let best_host = (0..segs.len())
.filter(|&a| {
@@ -1494,7 +1495,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
let a = if b < a { a - 1 } else { a };
segs[a].merge(sb);
merged = true;
- break;
+ } else {
+ b += 1;
}
}
}
@@ -2804,5 +2806,17 @@ mod tests {
);
assert_eq!(justified.plain_text(), "Justified clause.1 Next sentence on the same line\nKerned clause.2 continues cleanly");
assert_eq!(justified.line_rects(0..justified.glyphs.len()).len(), 2);
+
+ // A line that begins with a raised superscript marker and continues with taller body text
+ // adopts the body text's vertical band (Seg::extend_box first branch) so a later
+ // out-of-order superscript on that line still measures against the body baseline.
+ let leading_script = page_glyphs(
+ "BT /F1 8 Tf 1 0 0 1 40 300 Tm 6 Ts (1) Tj 0 Ts \
+ /F1 12 Tf ( See the appendix) Tj \
+ 1 0 0 1 146 300 Tm (for details) Tj \
+ /F1 8 Tf 1 0 0 1 139 303.6 Tm (2) Tj ET",
+ );
+ assert_eq!(leading_script.plain_text(), "1 See the appendix2 for details");
+ assert_eq!(leading_script.line_rects(0..leading_script.glyphs.len()).len(), 1);
}
}
From 22064736cd7bd9a7163a60089e19bd27dced36fa Mon Sep 17 00:00:00 2001
From: jonathanwxh-cell
Date: Sun, 18 Oct 2026 11:00:07 +0800
Subject: [PATCH 018/270] M2: pin leading superscript line geometry regression
(#2929)
---
ROADMAP.md | 2 ++
crates/render/src/text.rs | 27 ++++++++++++++++++++++++++-
docs/roadmap.md | 2 +-
3 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index c895ed2c3d..9501136aab 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -160,6 +160,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+
+- **2026-10-18 (M2, #2929):** Pin leading-superscript segment height and baseline separately from the selection highlight, which includes the raised marker. Regression assertions distinguish the body band from a union pulled upward by the marker; estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index b2a2d914c5..11bdd0c590 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -2817,6 +2817,31 @@ mod tests {
/F1 8 Tf 1 0 0 1 139 303.6 Tm (2) Tj ET",
);
assert_eq!(leading_script.plain_text(), "1 See the appendix2 for details");
- assert_eq!(leading_script.line_rects(0..leading_script.glyphs.len()).len(), 1);
+ let rects = leading_script.line_rects(0..leading_script.glyphs.len());
+ assert_eq!(rects.len(), 1);
+ let marker = leading_script.glyphs.first().unwrap().rect;
+ let body = leading_script.glyphs.iter().find(|g| g.text == "S").unwrap().rect;
+ let last = leading_script.glyphs.last().unwrap().rect;
+ assert!(marker[1] < body[1] && marker[3] < body[3], "marker must be raised above the body");
+ let close = |actual: f32, expected: f32| assert!((actual - expected).abs() < 0.001, "{actual} != {expected}");
+ // Highlight rectangles cover the actual glyphs, including the raised marker. They do
+ // not expose the segment's baseline band used for line grouping.
+ for (actual, expected) in rects[0].into_iter().zip([marker[0], marker[1], last[2], body[3]]) {
+ close(actual, expected);
+ }
+ close(rects[0][3] - rects[0][1], 14.8);
+ close(body[3] - 0.2 * 12.0, 100.0); // 300 pt PDF baseline on a 400 pt page.
+
+ // Pin that internal band too: replacing the first extend_box condition with `false`
+ // must fail here even when plain text and highlight rectangles remain unchanged.
+ let mut segment = Seg::new(Run { start: 0, end: 1 }, marker, marker[3] - marker[1]);
+ segment.extend_box(&body, body[3] - body[1]);
+ for (actual, expected) in segment.bbox.into_iter().zip([marker[0], body[1], body[2], body[3]]) {
+ close(actual, expected);
+ }
+ close(segment.h, 12.0);
+ close(segment.bbox[3] - segment.bbox[1], 12.0);
+ close(segment.bbox[3] - 0.2 * segment.h, 100.0);
+ close(segment.cy(), (body[1] + body[3]) / 2.0);
}
}
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 2c65496b6e..8bbf6e7b07 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -101,7 +101,7 @@ total matches the parity estimate.
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| 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). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Refreshed against upstream `99941daa` (2026-10-15). 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. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
+| 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. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Leading-superscript regression coverage checks the body segment height and baseline separately from glyph-union highlights (#2929). |
| 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. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. |
From eca16e6bd50f6261e9af557809eb842d199a56fe Mon Sep 17 00:00:00 2001
From: alehel
Date: Sun, 18 Oct 2026 16:24:14 +0200
Subject: [PATCH 019/270] fix(edit): backdrop copies keep the stream dictionary
and every slot
Review of #2815 (CodeRabbit):
- A content stream whose bytes live in an external file (`/F`) draws
those, not its inline bytes, so it is never taken for a backdrop.
- The copy keeps the original stream dictionary, unknown entries
included, less `Length` and `DL` (Flate re-encodes it); it had started
from an empty dictionary.
- Every `/Contents` slot that lists the original stream gets the copy: a
page listing the same stream twice painted the white fill again over
the background.
- The Background dialog starts with replacing on each time it opens.
Update restores backdrops before re-applying, so a replaced page shows
no fill and no checkbox, and a choice left off from an earlier dialog
hid the background again unseen.
Co-Authored-By: Claude Opus 5.5
---
crates/edit/src/backdrop.rs | 29 +++++++++++---
crates/edit/src/backdrop_tests.rs | 52 ++++++++++++++++++++++++++
crates/ui-egui/src/commands.rs | 1 +
crates/ui-egui/src/lib.rs | 3 ++
crates/ui-egui/src/marks_ui.rs | 12 ++++++
crates/ui-egui/tests/ui_group/marks.rs | 16 +++++---
6 files changed, 102 insertions(+), 11 deletions(-)
diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs
index 967a8f9121..86924b8cb7 100644
--- a/crates/edit/src/backdrop.rs
+++ b/crates/edit/src/backdrop.rs
@@ -80,7 +80,7 @@ pub(crate) fn replace(doc: &mut Document, page: &pdfcraft_model::Page) -> Result
let Some((Backdrop::Fill, Some(found))) = scan(doc, page) else { return Ok(false) };
let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]);
let piece = found.pieces.piece_of(found.op.start);
- let (Some(&(slot, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) };
+ let (Some(&(_, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) };
// Where the operator sits in that piece: the joined offset less the pieces before it.
let before: usize = (0..piece).filter_map(|i| edited.get(i)).map(|p| p.len() + 1).sum();
let at = found.op.start.saturating_sub(before);
@@ -89,14 +89,27 @@ pub(crate) fn replace(doc: &mut Document, page: &pdfcraft_model::Page) -> Result
note.set(b"Original".to_vec(), Object::Ref(original));
note.set(b"At".to_vec(), Object::Int(i64::try_from(at).unwrap_or(i64::MAX)));
note.set(b"Len".to_vec(), Object::Int(i64::try_from(len).unwrap_or(i64::MAX)));
- let mut dict = Dict::new();
+ // The copy keeps the original's dictionary (unknown entries included) less what describes
+ // the old encoding; `scan` refused streams whose bytes live in an external file (`/F`).
+ let Object::Stream(source) = &*doc.get(original) else { return Ok(false) };
+ let mut dict = source.dict.clone();
+ for key in [&b"Length"[..], b"DL", b"PCBackdrop"] {
+ dict.remove(key);
+ }
dict.set(b"PCBackdrop".to_vec(), Object::Dict(note));
let copy = doc.add(Object::Stream(Stream::flate(dict, bytes)));
+ // Every slot that draws the original: a page may list the same stream twice, and a second
+ // copy of the fill would cover the background again.
let mut list = crate::contents(doc, page)?;
- let Some(entry) = list.get_mut(slot) else { return Ok(false) };
- *entry = Object::Ref(copy);
- doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
- Ok(true)
+ let mut replaced = false;
+ for entry in list.iter_mut().filter(|o| matches!(o, Object::Ref(r) if *r == original)) {
+ *entry = Object::Ref(copy);
+ replaced = true;
+ }
+ if replaced {
+ doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
+ }
+ Ok(replaced)
}
/// Put back the original content streams [`replace`] copied on `page`, where the copy is still
@@ -158,6 +171,10 @@ fn scan(doc: &Document, p: &pdfcraft_model::Page) -> Option<(Backdrop, Option>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(),
+ stream("/F (content.bin)", &format!("1 g 0 0 300 200 re f {TEXT}")),
+ ];
+ let mut doc = build(&objs);
+ assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "the inline bytes are not what the page draws");
+ let before = streams(&doc);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ assert_eq!(streams(&doc)[1..], before[..]);
+}
+
+#[test]
+fn the_backdrop_copy_keeps_the_original_dictionary_less_its_old_encoding() {
+ let objs = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(),
+ stream("/Vendor (keep me) /DL 9999", &format!("1 g 0 0 300 200 re f {TEXT}")),
+ ];
+ let mut doc = build(&objs);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ let doc = reopen(&doc);
+ let p = &pdfcraft_model::pages(&doc)[0];
+ let Some(Object::Array(list)) = p.dict.get(b"Contents").map(|c| (*doc.resolve(c)).clone()) else { panic!("contents") };
+ let Object::Stream(copy) = &*doc.resolve(&list[1]) else { panic!("a stream") };
+ assert!(copy.dict.get(b"Vendor").is_some(), "unknown entries survive");
+ assert!(copy.dict.get(b"DL").is_none(), "the old decoded length no longer holds");
+ assert!(copy.dict.get(b"PCBackdrop").is_some());
+ assert_eq!(String::from_utf8_lossy(©.decoded().unwrap()), format!("1 g 0 0 300 200 re n {TEXT}"));
+}
+
+#[test]
+fn a_stream_listed_twice_is_replaced_in_both_slots_and_restored_in_both() {
+ let mut doc = build(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R] >>".into(),
+ stream("", &format!("q 1 g 0 0 300 200 re f Q {TEXT}")),
+ ]);
+ let original = streams(&doc);
+ add_background(&mut doc, &[0], &red(), false).unwrap();
+ let s = streams(&doc);
+ assert_eq!(s.len(), 3, "{s:?}");
+ assert!(s[1..].iter().all(|c| c.contains("re n") && !c.contains("re f")), "no slot still paints the white fill: {s:?}");
+ remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
+ assert_eq!(streams(&doc), original);
+}
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 832ce7cfbd..d32aedeef9 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -371,6 +371,7 @@ impl PdfCraftApp {
K::Background
};
self.marks_draft.replace = id.ends_with(".update");
+ self.marks_draft.opened(kind);
self.dialog = Some(Dialog::Marks(kind));
}
"edit.bates" => {
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 725bcad4b0..3e817d2717 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2557,6 +2557,9 @@ impl PdfCraftApp {
}
"none" => None,
_ => Some(Dialog::About),
+ };
+ if let Some(Dialog::Marks(kind)) = self.dialog {
+ self.marks_draft.opened(kind);
}
}
("tools", _) => self.all_tools_expanded = value != "collapsed",
diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs
index c838f7861e..a555b437e2 100644
--- a/crates/ui-egui/src/marks_ui.rs
+++ b/crates/ui-egui/src/marks_ui.rs
@@ -136,6 +136,18 @@ impl BackdropCheck {
}
}
+impl MarksDraft {
+ /// The dialog for `kind` is opening. Other settings carry over from last time, but replacing
+ /// a page's own backdrop starts on: Update restores backdrops before it re-applies, so a
+ /// page whose backdrop was replaced shows none and the checkbox stays hidden, and a choice
+ /// left off from an earlier dialog would hide the background again unseen.
+ pub fn opened(&mut self, kind: MarkKind) {
+ if kind == MarkKind::Background {
+ self.bg.replace_backdrop = true;
+ }
+ }
+}
+
/// "1 page" or "{n} pages", in a message.
fn pages_phrase(n: usize, one: &str, many: &str) -> String {
if n == 1 { one.to_string() } else { crate::i18n::fmt(many, &[("n", &n.to_string())]) }
diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs
index 3a9864ea47..20b8a41a19 100644
--- a/crates/ui-egui/tests/ui_group/marks.rs
+++ b/crates/ui-egui/tests/ui_group/marks.rs
@@ -148,18 +148,24 @@ fn the_background_dialog_offers_to_replace_a_white_backdrop_and_warns_about_scan
assert_eq!(backdrop(&h, 0), Some(pdfcraft_engine::Backdrop::Fill));
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(2);
- // Ticked, it is replaced; the scan is reported, not touched.
+ // Reopened, the choice starts on again; ticked, the backdrop is replaced and the scan is
+ // reported, not touched.
h.state_mut().execute("edit.background");
h.run_steps(3);
- h.get_by_label(replace).click();
- h.run_steps(2);
- assert!(h.state().marks_draft.bg.replace_backdrop);
+ h.get_by_label(replace);
+ assert!(h.state().marks_draft.bg.replace_backdrop, "on again when the dialog opens");
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(backdrop(&h, 0), None);
assert_eq!(backdrop(&h, 1), Some(pdfcraft_engine::Backdrop::Image));
- // Nothing left to replace: the offer is gone.
+ // Nothing left to replace: the offer is gone. A choice left off from an earlier dialog
+ // doesn't carry into Update, which restores the backdrop first and would otherwise keep it
+ // with no checkbox shown.
+ h.state_mut().marks_draft.bg.replace_backdrop = false;
h.state_mut().execute("edit.background.update");
h.run_steps(3);
assert!(h.query_by_label(replace).is_none());
+ h.get_by_label("OK").click();
+ h.run_steps(3);
+ assert_eq!(backdrop(&h, 0), None, "Update keeps the backdrop replaced");
}
From 2512b49f803ed74d50932076b8ba8ffcc7fd2e2e Mon Sep 17 00:00:00 2001
From: jonathanwxh-cell
Date: Mon, 19 Oct 2026 04:59:52 +0800
Subject: [PATCH 020/270] test(render): derive leading-superscript selection
range via Text::nearest (#2960)
---
crates/render/src/text.rs | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index 7f1cd3aea6..e4b883c351 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -2966,11 +2966,17 @@ mod tests {
/F1 8 Tf 1 0 0 1 139 303.6 Tm (2) Tj ET",
);
assert_eq!(leading_script.plain_text(), "1 See the appendix2 for details");
- let rects = leading_script.line_rects(0..leading_script.glyphs.len());
- assert_eq!(rects.len(), 1);
let marker = leading_script.glyphs.first().unwrap().rect;
let body = leading_script.glyphs.iter().find(|g| g.text == "S").unwrap().rect;
let last = leading_script.glyphs.last().unwrap().rect;
+ let center = |r: [f32; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
+ let (start_x, start_y) = center(marker);
+ let (end_x, end_y) = center(last);
+ let start = leading_script.nearest(start_x, start_y).unwrap();
+ let end = leading_script.nearest(end_x, end_y).unwrap();
+ assert_eq!(leading_script.text_of(start..end + 1), "1 See the appendix2 for details");
+ let rects = leading_script.line_rects(start..end + 1);
+ assert_eq!(rects.len(), 1);
assert!(marker[1] < body[1] && marker[3] < body[3], "marker must be raised above the body");
let close = |actual: f32, expected: f32| assert!((actual - expected).abs() < 0.001, "{actual} != {expected}");
// Highlight rectangles cover the actual glyphs, including the raised marker. They do
From 198f551fa7d48c137578c291ada21b02f5837843 Mon Sep 17 00:00:00 2001
From: srt8hellcat2-ui
Date: Sun, 18 Oct 2026 19:57:26 -0400
Subject: [PATCH 021/270] docs: correct two-paged PR publication status
---
docs/roadmap.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index cde798d466..444d7b0633 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -113,7 +113,7 @@ total matches the parity estimate.
| 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. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| 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. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. #1354 local candidate: --two-paged overrides follow named PDFs through background opens and password retries, preserve defaults and respect single-tab reuse. Native regressions, visual inspection, strict scoped Clippy and scoped WASM compilation pass; three existing Unix-socket tests require an unrestricted runtime. No push or GitHub merge. |
+| 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. #1354 local candidate: --two-paged overrides follow named PDFs through background opens and password retries, preserve defaults and respect single-tab reuse. Native regressions, visual inspection, strict scoped Clippy and scoped WASM compilation pass; three existing Unix-socket tests require an unrestricted runtime. The PR branch is pushed but not merged into upstream. |
| 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
| 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. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| 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. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
From ec34fc2c2d8b87cdfe9b7063a57a74eb6c507f73 Mon Sep 17 00:00:00 2001
From: jonathanwxh-cell
Date: Mon, 19 Oct 2026 12:54:45 +0800
Subject: [PATCH 022/270] fix(ui): pin session clock in prepare button-label
test to prevent ModDate second-boundary flake
---
crates/ui-egui/src/dialogs.rs | 1 +
crates/ui-egui/tests/prepare.rs | 2 ++
2 files changed, 3 insertions(+)
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 981db214b2..9f5d18dc3a 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1387,6 +1387,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
app.props_draft = None;
app.view_draft = None;
app.bulk_field_props = None;
+ app.field_props = None;
} else {
app.dialog = Some(next);
}
diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs
index d63df5232c..3e9d80c0ae 100644
--- a/crates/ui-egui/tests/prepare.rs
+++ b/crates/ui-egui/tests/prepare.rs
@@ -254,6 +254,8 @@ fn options_tab_sets_the_check_box_style() {
fn button_label_is_editable_without_renaming_the_field() {
use pdfcraft_engine::{Edit, NewField};
let mut h = harness();
+ // Saves stamp /ModDate from the clock; pinned, two saves compare equal across a second.
+ h.state_mut().session = std::mem::take(&mut h.state_mut().session).with_clock(|| 1_700_000_000);
h.state_mut().apply_edit(Edit::AddField {
page: 0,
rect: [40.0, 300.0, 190.0, 330.0],
From 5b3b87430b493b7dc94bffdb13183bfd751ddc5e Mon Sep 17 00:00:00 2001
From: Philip
Date: Mon, 19 Oct 2026 16:41:46 +0100
Subject: [PATCH 023/270] M2: review of #1567: read each installed font file
once and share it
CodeRabbit: the fallback cache was keyed by (script, weight) and every key read
and kept its own copy of the file, so PingFang.ttc (tens of megabytes, a
candidate of several scripts and both weights) could be held up to four times.
The bytes now come from a process-wide cache keyed by path, remembering files
that cannot be used too; the per-script cache keeps a handle and a face index.
Co-Authored-By: Claude Sonnet 5.5
---
crates/render/src/system_font.rs | 47 ++++++++++++++++++++++++++++----
1 file changed, 42 insertions(+), 5 deletions(-)
diff --git a/crates/render/src/system_font.rs b/crates/render/src/system_font.rs
index d2c98bb7cd..0d9d158a35 100644
--- a/crates/render/src/system_font.rs
+++ b/crates/render/src/system_font.rs
@@ -86,13 +86,30 @@ pub(crate) fn fallback(query: &FallbackFontQuery) -> Option<(FontData, u32)> {
}
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 {
+ let bytes = file_bytes(&candidate.path)?;
+ let index = pick_face(&bytes, probe, candidate.region, bold)?;
+ Some((bytes, index))
+}
+
+/// The bytes of an installed font file, read once for the whole process and shared: a collection
+/// such as `PingFang.ttc` is tens of megabytes and is the candidate of several scripts and both
+/// weights, so the per-script cache keeps only a handle and a face index. A file that cannot be
+/// used is remembered too, so it is not tried again for every script.
+fn file_bytes(path: &Path) -> Option>> {
+ /// The bytes of each file tried, or `None` for one that cannot be used.
+ type Files = HashMap>>>;
+ static FILES: Mutex> = Mutex::new(None);
+ let mut files = FILES.lock().unwrap_or_else(PoisonError::into_inner);
+ files.get_or_insert_with(HashMap::new).entry(path.to_path_buf()).or_insert_with(|| read_file(path, MAX_BYTES).map(Arc::new)).clone()
+}
+
+/// A regular file of at most `max` bytes, or `None`: a font path is untrusted input.
+fn read_file(path: &Path, max: u64) -> Option> {
+ let meta = std::fs::metadata(path).ok()?;
+ if !meta.is_file() || meta.len() > max {
return None;
}
- let bytes = std::fs::read(&candidate.path).ok()?;
- let index = pick_face(&bytes, probe, candidate.region, bold)?;
- Some((Arc::new(bytes), index))
+ std::fs::read(path).ok()
}
/// The best face of the file that parses and maps `probe`: the region's face when the file has
@@ -199,6 +216,26 @@ mod tests {
assert!(read(&dir, '\u{7269}', false).is_none());
}
+ /// The files are bounded by `max`: a larger one is not read.
+ #[test]
+ fn a_file_over_the_cap_is_not_read() {
+ let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
+ assert!(read_file(inter, MAX_BYTES).is_some());
+ assert!(read_file(inter, 1024).is_none());
+ }
+
+ /// Two faces drawn from one installed file (two scripts, or regular and bold) hold the same
+ /// bytes, not a copy each; a file that can't be used is remembered as unusable.
+ #[test]
+ fn one_installed_file_is_read_once_and_shared() {
+ let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
+ let first = file_bytes(inter).expect("readable");
+ let second = file_bytes(inter).expect("readable");
+ assert!(Arc::ptr_eq(&first, &second), "one allocation for the file");
+ let missing = Path::new("definitely/not/here.ttc");
+ assert!(file_bytes(missing).is_none() && file_bytes(missing).is_none());
+ }
+
#[test]
fn a_latin_face_is_rejected_for_every_script() {
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
From 102ceecf663fa7a506bbed8e4c38236ee574cc13 Mon Sep 17 00:00:00 2001
From: Philip
Date: Mon, 19 Oct 2026 18:22:03 +0100
Subject: [PATCH 024/270] M2: review of #1567: find PingFang in the macOS font
assets
CodeRabbit: on recent macOS the PingFang collection is not in
/System/Library/Fonts but in the downloaded font assets
(/System/Library/AssetsV2/com_apple_MobileAsset_Font*/.asset/AssetData), so
the fixed path missed it, and the PingFang test returned early without checking
anything on such a machine. The macOS candidates now include the AssetData
folders of those assets (listed once, at most 512 per kind), and a test
searches the assets independently and requires the candidate list to contain
what it finds.
Co-Authored-By: Claude Sonnet 5.5
---
crates/render/src/system_font.rs | 73 ++++++++++++++++++++++++++++++--
1 file changed, 69 insertions(+), 4 deletions(-)
diff --git a/crates/render/src/system_font.rs b/crates/render/src/system_font.rs
index 0d9d158a35..40543cf46e 100644
--- a/crates/render/src/system_font.rs
+++ b/crates/render/src/system_font.rs
@@ -11,7 +11,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex, PoisonError};
+use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use hayro::hayro_interpret::font::{FallbackFontQuery, FontData};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
@@ -23,6 +23,8 @@ use skrifa::string::StringId;
const MAX_BYTES: u64 = 96 << 20;
/// Faces tried in a collection (`.ttc`).
const MAX_FACES: u32 = 32;
+/// Downloaded font assets looked at per kind on macOS.
+const MAX_ASSETS: usize = 512;
/// The writing system a character collection is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -163,7 +165,11 @@ fn candidates(script: Script) -> Vec {
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()
+ // The system folder first, then the downloaded font assets: on recent macOS a face such as
+ // PingFang lives in `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/.asset/AssetData`.
+ let mut dirs = vec![PathBuf::from("/System/Library/Fonts")];
+ dirs.extend(macos_font_asset_dirs().iter().cloned());
+ names.iter().flat_map(|f| dirs.iter().map(move |d| d.join(f))).collect()
} else {
let names: &[&str] = match script {
Script::Korean => {
@@ -193,6 +199,28 @@ fn candidates(script: Script) -> Vec {
files.into_iter().map(|path| Candidate { path, region }).collect()
}
+/// The `AssetData` folders of the downloaded font assets of macOS, found once. The ids in their
+/// names change with each asset version, so they are listed rather than named; at most
+/// [`MAX_ASSETS`] are looked at.
+fn macos_font_asset_dirs() -> &'static [PathBuf] {
+ static DIRS: OnceLock> = OnceLock::new();
+ DIRS.get_or_init(|| {
+ let root = Path::new("/System/Library/AssetsV2");
+ let Ok(kinds) = std::fs::read_dir(root) else { return Vec::new() };
+ let mut dirs = Vec::new();
+ for kind in kinds.flatten().filter(|k| k.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font")) {
+ let Ok(assets) = std::fs::read_dir(kind.path()) else { continue };
+ for asset in assets.flatten().take(MAX_ASSETS) {
+ let data = asset.path().join("AssetData");
+ if data.is_dir() {
+ dirs.push(data);
+ }
+ }
+ }
+ dirs
+ })
+}
+
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")
@@ -273,8 +301,15 @@ mod tests {
#[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 };
+ // Found where this macOS keeps it: the system folder, or the downloaded font assets.
+ let path = candidates(Script::TraditionalChinese)
+ .into_iter()
+ .map(|c| c.path)
+ .find(|p| p.file_name().is_some_and(|n| n == "PingFang.ttc") && p.is_file());
+ let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
+ eprintln!("PingFang is not installed here: skipped");
+ 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();
@@ -282,4 +317,34 @@ mod tests {
assert!(family.contains(region), "{script:?} got {family:?}");
}
}
+ /// A face that this macOS keeps only in its downloaded font assets (PingFang, on recent
+ /// versions) is among the candidates. The assets are searched here on their own, not by the
+ /// code under test; where there are none, nothing is checked.
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn macos_candidates_include_a_pingfang_that_lives_in_the_font_assets() {
+ fn find(dir: &Path, name: &str, depth: u32) -> Option {
+ for entry in std::fs::read_dir(dir).ok()?.flatten() {
+ let path = entry.path();
+ if path.file_name().is_some_and(|n| n == name) && path.is_file() {
+ return Some(path);
+ }
+ if depth > 0
+ && path.is_dir()
+ && !path.is_symlink()
+ && let Some(found) = find(&path, name, depth - 1)
+ {
+ return Some(found);
+ }
+ }
+ None
+ }
+ let Some(asset) = find(Path::new("/System/Library/AssetsV2"), "PingFang.ttc", 3) else {
+ eprintln!("no PingFang in the font assets here: skipped");
+ return;
+ };
+ for script in [Script::SimplifiedChinese, Script::TraditionalChinese] {
+ assert!(candidates(script).iter().any(|c| c.path == asset), "{script:?}: {} is not a candidate", asset.display());
+ }
+ }
}
From 57a16aea171f5e015a4de17d9c47f1e5fb22a646 Mon Sep 17 00:00:00 2001
From: Philip
Date: Wed, 21 Oct 2026 20:17:27 +0100
Subject: [PATCH 025/270] M2: review of #1567: draw installed CJK fonts on
screen only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FredApps: AGENTS.md §1.4 allows reading an installed font at runtime only as
the interface's last fallback and says nothing from it may enter a PDF or
package, while the renderer fallback also served exports and printing.
RenderConfig gains system_fonts, off by default. The engine sets it only on the
pool that draws the viewer (display_config); exports, printing, OCR and the
automation tools render with the document's own configuration, so nothing drawn
with an installed font is saved or printed. Tests: the render of a non-embedded
CNS1, GB1 and Korea1 font is blank without the flag and drawn with it; the
viewer's pool draws such a page and an export of it does not (it fails if the
flag is on everywhere). No Adobe-named font can be a candidate.
Co-Authored-By: Claude Sonnet 5.5
---
ROADMAP.md | 2 +-
crates/engine/src/lib.rs | 26 +++++++++++------
crates/engine/src/tests.rs | 49 ++++++++++++++++++++++++++++++++
crates/render/src/raster.rs | 28 ++++++++++++++----
crates/render/src/system_font.rs | 8 ++++--
5 files changed, 95 insertions(+), 18 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 38f5ff3e5f..bc916e033c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -214,7 +214,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
-- **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 (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, and used only for the on-screen viewer, never for exports, printing or OCR; 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-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged.
- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 8f39248abd..d1b94f157b 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1009,11 +1009,19 @@ fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
cos.set(root_ref, Object::Dict(root));
}
+/// The configuration of the pool that draws the viewer: the document's own, plus installed CJK
+/// fonts for fonts a PDF does not embed (`RenderConfig::system_fonts`). Exports, printing, OCR and
+/// the automation tools render with the document's configuration itself, so nothing drawn with an
+/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
+fn display_config(config: &RenderConfig) -> RenderConfig {
+ RenderConfig { system_fonts: true, ..config.clone() }
+}
+
/// Render `doc` with the layer visibility in its `info.layers`.
fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
@@ -3580,7 +3588,7 @@ impl Session {
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
let display = display_bytes(editor.as_mut(), &bytes);
- let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
+ let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config));
let (form, adopted) = editor
.as_ref()
.map(|e| {
@@ -3962,7 +3970,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
@@ -4007,7 +4015,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
doc.fonts_stale = true;
Ok(())
@@ -4032,7 +4040,7 @@ impl Session {
/// After a refresh failed: draw the display bytes it left (the original, once released),
/// so the page still shows a document.
fn show_released(doc: &mut Document) {
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
@@ -4082,7 +4090,7 @@ impl Session {
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
@@ -4795,7 +4803,7 @@ impl Session {
return false;
}
doc.config.hide_comments = hide;
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
@@ -4808,7 +4816,7 @@ impl Session {
return false;
}
doc.config.reading_colors = colors;
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
@@ -4844,7 +4852,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &doc.bytes);
doc.signatures = Arc::new(signatures);
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index f125c41dac..5e6ef3b301 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -385,6 +385,55 @@ fn edits_update_view_data_and_mark_dirty() {
assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed");
}
+/// A font of an Adobe CJK collection that a PDF does not embed is drawn with an installed face on
+/// the viewer's pool only. An export of the same page, which can be saved, has nothing drawn with
+/// an installed font (AGENTS.md §1.2, §1.4). Skipped where no such face is installed.
+#[test]
+fn installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export() {
+ let content = "BT /F1 40 Tf 5 15 Td <7E419AD45B57> 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 /MSung-Light /Encoding /UniCNS-UCS2-H /DescendantFonts [6 0 R] >> endobj
+6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /MSung-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (CNS1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj
+7 0 obj << /Type /FontDescriptor /FontName /MSung-Light /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 s = Session::new();
+ let id = s.open("cns1.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap();
+ let doc = s.get(id).unwrap();
+ doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest {
+ page: 0,
+ kind: pdfcraft_render::RequestKind::Pixels,
+ scale: 1.0,
+ ..Default::default()
+ }]);
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
+ let viewer = loop {
+ if let Some(page) = doc.renderer.try_recv() {
+ assert!(page.error.is_none(), "{:?}", page.error);
+ break page;
+ }
+ assert!(std::time::Instant::now() < deadline, "page render did not finish");
+ std::thread::yield_now();
+ };
+ let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
+ if inked(&viewer.rgba) < 300 {
+ eprintln!("no installed Traditional Chinese face (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
+ return;
+ }
+ let png = export::Exporter::new(doc).png(0, 72.0).unwrap();
+ let exported = image::load_from_memory(&png).unwrap().into_rgba8();
+ assert!(inked(exported.as_raw()) < 50, "an export draws nothing with an installed font ({} dark pixels)", inked(exported.as_raw()));
+}
+
#[test]
fn metadata_edit_keeps_the_renderer_without_changing_page_content() {
let (mut s, id) = session_with(1);
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index 2ef4870fe0..01aa89d69f 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -65,15 +65,23 @@ pub struct RenderConfig {
/// View ▸ Reading colours: solid text and line-art paints are remapped for readability and
/// the page clears to the setting's background. Display only; the document never changes.
pub reading_colors: ReadingColors,
+ /// Draw a CID font of an Adobe CJK collection that the PDF does not embed with one face already
+ /// installed on this machine (see `system_font`). Off by default and on only for the pool that
+ /// draws the viewer: exports, printing and OCR render without it, so nothing drawn with an
+ /// installed font is saved or printed (AGENTS.md §1.2, §1.4).
+ pub system_fonts: bool,
}
impl RenderConfig {
fn settings(&self) -> InterpreterSettings {
let standard = InterpreterSettings::default().font_resolver;
+ let system_fonts = self.system_fonts;
InterpreterSettings {
ocg_overrides: self.layers.clone(),
hide_comments: self.hide_comments,
- font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| system_fallback(query)).or_else(|| standard(query))),
+ font_resolver: Arc::new(move |query| {
+ japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
+ }),
..InterpreterSettings::default()
}
}
@@ -6751,12 +6759,20 @@ 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();
+ let pdf = Arc::new(pdf.into_bytes());
+ let ink = |config: RenderConfig| {
+ let mut r = PageRenderer::new(pdf.clone(), config);
+ 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);
+ 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)");
+ let shown = ink(RenderConfig { system_fonts: true, ..RenderConfig::default() });
+ assert!(shown > 300, "{base_font}: the text is drawn ({shown} dark pixels)");
+ // Off by default: exports, printing and OCR render without an installed font, so
+ // nothing drawn with one can be saved (AGENTS.md §1.2, §1.4).
+ let saved = ink(RenderConfig::default());
+ assert!(saved < 50, "{base_font}: not drawn without system_fonts ({saved} dark pixels)");
}
}
diff --git a/crates/render/src/system_font.rs b/crates/render/src/system_font.rs
index 40543cf46e..5e6391f0d2 100644
--- a/crates/render/src/system_font.rs
+++ b/crates/render/src/system_font.rs
@@ -5,7 +5,8 @@
//! `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
+//! already installed on this machine, used for the viewer only (`RenderConfig::system_fonts`, off
+//! for exports, printing and OCR). 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.
@@ -282,7 +283,10 @@ mod tests {
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}");
+ assert!(
+ !["notosanscjk", "notoserifcjk", "sourcehan", "source-han", "adobe", "kozuka", "minion"].iter().any(|b| name.contains(b)),
+ "{name}"
+ );
}
}
}
From e42c21a3c833df30ba1144e33cf8f18e4a68f1f6 Mon Sep 17 00:00:00 2001
From: Philip
Date: Wed, 21 Oct 2026 20:17:27 +0100
Subject: [PATCH 026/270] =?UTF-8?q?docs:=20AGENTS.md=20=C2=A71.4=20?=
=?UTF-8?q?=E2=80=94=20the=20viewer=20may=20draw=20non-embedded=20CJK=20fo?=
=?UTF-8?q?nts=20with=20an=20installed=20face?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Proposed for the maintainers to accept or drop (separate commit): the page
viewer, on screen only, may use one installed face for a CID font of an Adobe
CJK collection that a PDF does not embed. Exports, printing, OCR and the
automation tools never use it; nothing drawn with it is saved or printed; no
Noto CJK, Source Han or Adobe family is a candidate.
Co-Authored-By: Claude Sonnet 5.5
---
AGENTS.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/AGENTS.md b/AGENTS.md
index 0665ad1d3b..1cb3e1c34a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -71,6 +71,7 @@ Anything else is forbidden, including assets whose licence you cannot prove from
- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal).
- craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR= cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`.
- The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository.
+- The page viewer may also draw a CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF does **not embed** with one face already installed on the machine (`crates/render/src/system_font.rs`), **on screen only**. `RenderConfig::system_fonts` is off by default and is set only for the pool that draws the viewer (`display_config` in `crates/engine/src/lib.rs`), so exports, printing, OCR and the automation tools never use it. Like the interface font it is read at runtime and never embedded, shipped, committed or copied; Noto CJK and Source Han are never candidates (§1.1), nor is any Adobe family; and `PDFCRAFT_SYSTEM_FONTS=0` turns it off. It is not a way around §1.2 either: nothing drawn with it may be saved or printed.
### 1.5 Procedure for adding or changing an asset
From 48955694765b65363ffd513122e9d5f7fabd0ef9 Mon Sep 17 00:00:00 2001
From: Philip
Date: Wed, 21 Oct 2026 21:45:28 +0100
Subject: [PATCH 027/270] M2: review of #1567: the viewer-wiring test no longer
skips when the wiring is lost
CodeRabbit: the engine test skipped when the viewer's render was blank, so a
viewer pool that lost the installed-font fallback would have skipped instead of
failing. Whether this machine has a face that can draw the page is now asked of
a renderer with RenderConfig::system_fonts switched on by hand, and the viewer's
render must then draw it. Without the fallback on the viewer's pool the test
fails.
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/tests.rs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 5e6ef3b301..867afc8a07 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -425,10 +425,16 @@ trailer << /Root 1 0 R >>
std::thread::yield_now();
};
let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
- if inked(&viewer.rgba) < 300 {
+ // Whether this machine has a face that can draw the page at all, asked of a renderer with the
+ // fallback switched on by hand, so a viewer pool that lost it fails below instead of skipping.
+ let probe = pdfcraft_render::PageRenderer::new(doc.display.clone(), pdfcraft_render::RenderConfig { system_fonts: true, ..Default::default() })
+ .render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
+ assert!(probe.error.is_none(), "{:?}", probe.error);
+ if inked(&probe.rgba) < 300 {
eprintln!("no installed Traditional Chinese face (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
return;
}
+ assert!(inked(&viewer.rgba) > 300, "the viewer's pool draws it with the installed face ({} dark pixels)", inked(&viewer.rgba));
let png = export::Exporter::new(doc).png(0, 72.0).unwrap();
let exported = image::load_from_memory(&png).unwrap().into_rgba8();
assert!(inked(exported.as_raw()) < 50, "an export draws nothing with an installed font ({} dark pixels)", inked(exported.as_raw()));
From c05f00c67a148b66235c61f05dbdbf8773b48b25 Mon Sep 17 00:00:00 2001
From: jonathanwxh-cell
Date: Thu, 22 Oct 2026 16:07:14 +0800
Subject: [PATCH 028/270] fix(engine): generate Protect keys and salts with
getrandom (#4000)
---
Cargo.lock | 1 +
crates/engine/Cargo.toml | 4 +++
crates/engine/src/lib.rs | 27 +++++++-------
crates/engine/src/tests.rs | 72 ++++++++++++++++++++++++++++++++++++++
4 files changed, 91 insertions(+), 13 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index a950149243..caa23b2662 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4109,6 +4109,7 @@ version = "0.11.0"
dependencies = [
"chrono",
"craft-docpath",
+ "getrandom 0.4.3",
"icu_properties",
"idna",
"image",
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index f5e35088b5..6dae6adf0a 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -51,6 +51,10 @@ unicode-segmentation = "1.13"
# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace
# entry is std-only; this target adds `clock` for `chrono::Local::now`.
chrono = { workspace = true, features = ["clock"] }
+getrandom = "0.4"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
pdfcraft-testkit = { workspace = true }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index e20cbc596e..e61d5fef36 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -2349,6 +2349,7 @@ struct EditCtx {
today: (i64, u32, u32),
seed: u64,
count: u64,
+ rng: fn(&mut [u8]) -> Result<(), getrandom::Error>,
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
/// log and the undo label.
renamed_fields: Vec<(String, String)>,
@@ -2377,23 +2378,20 @@ impl EditCtx {
today: (1970, 1, 1),
seed,
count: 0,
+ rng: getrandom::fill,
renamed_fields: Vec::new(),
}
}
- /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the
- /// operating system, so no extra dependency is needed.
- fn entropy(&mut self) -> [u8; 32] {
- use std::hash::{BuildHasher, Hasher};
+ /// 32 bytes of entropy for new encryption keys, salts and generated owner passwords from the
+ /// platform CSPRNG. Fails closed when secure randomness is unavailable.
+ fn entropy(&mut self) -> Result<[u8; 32], EditError> {
let mut out = [0u8; 32];
- for (i, chunk) in out.chunks_mut(8).enumerate() {
- let mut h = std::collections::hash_map::RandomState::new().build_hasher();
- h.write_u64(self.seed ^ i as u64);
- h.write_u64(self.count);
- self.count += 1;
- chunk.copy_from_slice(&h.finish().to_le_bytes());
+ (self.rng)(&mut out).map_err(|e| EditError::Protection(format!("no secure randomness available: {e}")))?;
+ if out == [0u8; 32] {
+ return Err(EditError::Protection("no secure randomness available".into()));
}
- out
+ Ok(out)
}
/// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream.
@@ -2806,10 +2804,13 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
}
Edit::Protect(p) => {
p.validate()?;
- let seed = cx.entropy();
+ let seed = cx.entropy()?;
// Without a permissions password nothing is restricted, so the owner password is a
// random one nobody needs.
- let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect();
+ let random_owner = match p.permissions_password {
+ Some(_) => String::new(),
+ None => cx.entropy()?.iter().map(|b| format!("{b:02x}")).collect(),
+ };
let params = pdfcraft_cos::NewEncryption {
algorithm: p.algorithm,
user_password: p.open_password.as_deref().unwrap_or(""),
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 0df176226a..6895f188f3 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1884,6 +1884,78 @@ fn passwords_that_encryption_would_make_empty_or_equal_are_refused() {
}
}
+#[test]
+fn fresh_sessions_protecting_identical_documents_produce_different_salts() {
+ let raw = Arc::new(fixture(1));
+ let encrypt_bytes = |saved: Arc>, key: &[u8]| -> Vec {
+ let doc = pdfcraft_cos::Document::open_with_password(saved, Some("secret")).unwrap();
+ let enc = doc.trailer().get(b"Encrypt").map(|o| doc.resolve(o)).unwrap();
+ enc.as_dict().unwrap().get(key).map(|o| doc.resolve(o)).unwrap().as_string().unwrap().bytes.clone()
+ };
+ let protect_once = |p: Protection| -> Arc> {
+ let mut s = Session::new();
+ let id = s.open("same.pdf", None, raw.clone(), None).unwrap();
+ s.apply(id, Edit::Protect(p)).unwrap();
+ s.save_bytes(id).unwrap()
+ };
+ let a = protect_once(protection(Some("secret"), None));
+ let b = protect_once(protection(Some("secret"), None));
+ let (u_a, u_b) = (encrypt_bytes(a.clone(), b"U"), encrypt_bytes(b.clone(), b"U"));
+ let (o_a, o_b) = (encrypt_bytes(a.clone(), b"O"), encrypt_bytes(b.clone(), b"O"));
+ let (ue_a, ue_b) = (encrypt_bytes(a.clone(), b"UE"), encrypt_bytes(b.clone(), b"UE"));
+ let (oe_a, oe_b) = (encrypt_bytes(a, b"OE"), encrypt_bytes(b, b"OE"));
+ assert_eq!((u_a.len(), u_b.len(), o_a.len(), o_b.len()), (48, 48, 48, 48));
+ assert_ne!(&u_a[32..48], &u_b[32..48], "user validation and key salts must differ across fresh sessions");
+ assert_ne!(&o_a[32..48], &o_b[32..48], "owner validation and key salts must differ across fresh sessions");
+ assert_ne!(ue_a, ue_b, "wrapped user file keys must differ across fresh sessions");
+ assert_ne!(oe_a, oe_b, "wrapped owner file keys must differ across fresh sessions");
+
+ // Generated owner passwords in R4 (AES-128) must also come from CSPRNG entropy, even when the
+ // document already carries a fixed /ID and only an open password is set.
+ let mut with_id = pdfcraft_cos::Document::open(raw).unwrap();
+ let fixed_id = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true });
+ with_id.trailer_mut().set(b"ID".to_vec(), pdfcraft_cos::Object::Array(vec![fixed_id.clone(), fixed_id]));
+ let raw_with_id = Arc::new(pdfcraft_cos::write_full(&with_id, &Default::default()).unwrap());
+ let protect_r4 = || -> Vec {
+ let mut s = Session::new();
+ let id = s.open("same.pdf", None, raw_with_id.clone(), None).unwrap();
+ let p = Protection { algorithm: pdfcraft_cos::Algorithm::Aes128, ..protection(Some("secret"), None) };
+ s.apply(id, Edit::Protect(p)).unwrap();
+ encrypt_bytes(s.save_bytes(id).unwrap(), b"O")
+ };
+ assert_ne!(protect_r4(), protect_r4(), "generated owner passwords must differ across fresh sessions");
+}
+
+#[test]
+fn protect_fails_closed_when_randomness_is_unavailable() {
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ static CALLS: AtomicUsize = AtomicUsize::new(0);
+ fn fail_rng(_: &mut [u8]) -> Result<(), getrandom::Error> {
+ Err(getrandom::Error::UNSUPPORTED)
+ }
+ fn zero_rng(out: &mut [u8]) -> Result<(), getrandom::Error> {
+ out.fill(0);
+ Ok(())
+ }
+ fn fail_second_rng(out: &mut [u8]) -> Result<(), getrandom::Error> {
+ if CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
+ out.fill(0xA5);
+ Ok(())
+ } else {
+ Err(getrandom::Error::UNSUPPORTED)
+ }
+ }
+ CALLS.store(0, Ordering::SeqCst);
+ for rng in [fail_rng, zero_rng, fail_second_rng] {
+ let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
+ let mut cx = EditCtx::new(None, 0);
+ cx.rng = rng;
+ let err = run_edit(&mut doc, &Edit::Protect(protection(Some("secret"), None)), &mut cx);
+ assert!(matches!(err, Err(EditError::Protection(_))), "Protect must fail closed when CSPRNG fails: {err:?}");
+ assert!(doc.output_handler().is_none(), "document must stay unprotected when CSPRNG fails");
+ }
+}
+
/// A one-page form: a text field and a check box (with appearance states).
fn form_fixture() -> Vec {
let objs = [
From 2842eeaa713889b2fb306e72990050549ee1a5dd Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 13:38:28 +1100
Subject: [PATCH 029/270] fix(ui): Ctrl+Z in an open paragraph undoes the
document, not stale text
Ctrl+Z is left to a focused text field, so in the paragraph editor it
went to the box's own Undo history. That history still held the
paragraph's text from before the last formatting change, so Ctrl+Z put
it back into a box laid out for the page after the change, a layout
the page never had, and the document kept the change. The box shared
its id across paragraphs too, so it could put back another paragraph's
text.
The history of the paragraph editor and the added-text box now starts
at what the page has. Undo and Redo keep the keys only while the box
holds typing the page doesn't have; then they reach the document. An
open box is applied (as clicking away applies it) and closed before an
Undo or Redo step, so it never shows a state the document has left;
text it can't apply keeps it open and stops the step.
---
crates/ui-egui/src/commands.rs | 5 ++-
crates/ui-egui/src/content_ui.rs | 23 +++++++++++--
crates/ui-egui/src/edit_text_ui.rs | 29 +++++++++++++++-
crates/ui-egui/src/editing.rs | 54 +++++++++++++++++++++++++++++
crates/ui-egui/tests/it/editing.rs | 55 ++++++++++++++++++++++++++++++
5 files changed, 161 insertions(+), 5 deletions(-)
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 92bc0b360e..ee19c168eb 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -726,7 +726,10 @@ impl PdfCraftApp {
if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) {
continue;
}
- if typing && !spec.in_text {
+ // A box typed into on the page (a paragraph being edited, added text) keeps Undo and
+ // Redo only while it holds typing the page doesn't have; then they reach the document.
+ let page_box = matches!(spec.id, "edit.undo" | "edit.redo") && self.page_box_focused(ctx) && !self.page_typing_unapplied();
+ if typing && !spec.in_text && !page_box {
continue;
}
let Some(key) = Key::from_name(s.key) else { continue };
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 674210529e..892e94ed67 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -66,8 +66,18 @@ pub struct TextDraft {
/// Display space.
pub rect: [f64; 4],
pub text: String,
+ /// The box's formatting; its `text` is what the page has (empty for new text).
pub style: AddedText,
pub focus: bool,
+ /// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]).
+ pub typing_from: Option,
+}
+
+impl TextDraft {
+ /// Typing the page doesn't have yet: Undo takes it back in the box before touching the document.
+ pub(crate) fn has_unapplied_typing(&self) -> bool {
+ self.text != self.style.text
+ }
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -394,7 +404,7 @@ pub(crate) fn page_input(
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]];
style.rect = rect;
- cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true });
+ cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None });
consumed = true;
if typed.is_some() {
view.pending_edit = typed;
@@ -432,7 +442,7 @@ pub(crate) fn page_input(
if let Some(edit) = cv.finish_draft(added) {
view.pending_edit = Some(edit);
}
- cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true });
+ cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None });
cv.selected = Some((page, i));
consumed = true;
return consumed;
@@ -560,6 +570,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
+ let input = input_id();
+ crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let resp = ui.add(
@@ -570,7 +582,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.hint_text(tl!("Type text"))
- .id_salt("added-text-edit"),
+ .id(input),
);
if t.focus {
resp.request_focus();
@@ -633,6 +645,11 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option {
}
}
+/// The added-text box's text field.
+pub(crate) fn input_id() -> egui::Id {
+ egui::Id::new("added-text-input")
+}
+
/// How to use the tools, under the Add content list.
pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) {
ui.label(
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index 3a1ab04367..f2c5c55151 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -256,6 +256,29 @@ pub struct LineEditor {
/// horizontal scale (%), and what they were.
pub extras: Extras,
extras0: Extras,
+ /// The text the box's own Undo history starts from; see [`forget_typing_before`].
+ typing_from: Option,
+}
+
+/// The paragraph editor's text box.
+pub(crate) fn input_id() -> egui::Id {
+ egui::Id::new("edit-text-line-input")
+}
+
+/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown
+/// (they share the id). Once the page has `text` (the box opened on it, or formatting applied it),
+/// that history is cleared: Undo in the box takes back only what was typed since, and then reaches
+/// the document. Kept, it put back text from another paragraph, or the whole paragraph as it was
+/// before a format change, into a box showing the page as it is now.
+pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut Option, text: &str) {
+ if from.as_deref() == Some(text) {
+ return;
+ }
+ if let Some(mut state) = egui::TextEdit::load_state(ctx, id) {
+ state.clear_undoer();
+ egui::TextEdit::store_state(ctx, id, state);
+ }
+ *from = Some(text.to_owned());
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -1199,6 +1222,7 @@ fn open_editor(
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
+ typing_from: None,
});
}
@@ -1668,8 +1692,10 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width);
if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) }
};
+ let input = input_id();
+ forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original);
let output = egui::TextEdit::multiline(&mut ed.text)
- .id(egui::Id::new("edit-text-line-input"))
+ .id(input)
.font(font.clone())
.margin(egui::Margin::ZERO)
.layouter(&mut layouter)
@@ -1801,6 +1827,7 @@ mod tests {
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
+ typing_from: None,
};
(app, ed)
}
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index 5b821b6381..c10094b208 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -229,6 +229,9 @@ impl PdfCraftApp {
fn history_step(&mut self, undo: bool) {
let Some((i, id)) = self.active_ids() else { return };
+ if !self.close_text_editors(i, id) {
+ return;
+ }
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
match result {
Ok(label) => {
@@ -247,6 +250,57 @@ impl PdfCraftApp {
}
}
+ /// Before Undo or Redo: a paragraph or added text box open on the page shows the document as
+ /// it is before the step, so it is applied as clicking away applies it, then closed. Left
+ /// open, it kept showing what the step took away, and wrote from it when it was next applied.
+ /// What was typed is then the step Undo takes back. False (nothing is undone, and the box
+ /// stays open) while it holds text the standard fonts can't draw, or text that couldn't be
+ /// applied: it is shown, never lost.
+ pub(crate) fn close_text_editors(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool {
+ let Some(view) = self.views.get_mut(i) else { return true };
+ if let Some(c) = view.content.blocked() {
+ view.content.hold_blocked();
+ self.notify(crate::content_ui::undrawable_message(c));
+ return false;
+ }
+ if let Some(ed) = view.line_editor.take()
+ && let Some(edit) = crate::edit_text_ui::finished_edit(ed.clone())
+ && !self.apply_edit(edit)
+ {
+ if let Some(view) = self.views.get_mut(i) {
+ view.line_editor = Some(ed);
+ }
+ return false;
+ }
+ let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default();
+ let Some(view) = self.views.get_mut(i) else { return true };
+ let draft = view.content.draft.clone();
+ if let Some(edit) = view.content.finish_draft(&added)
+ && !self.apply_edit(edit)
+ {
+ if let Some(view) = self.views.get_mut(i) {
+ view.content.draft = draft;
+ }
+ return false;
+ }
+ true
+ }
+
+ /// Whether the keyboard is in a box typed into on the page: a paragraph being edited, or added
+ /// text.
+ pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool {
+ let focused = ctx.memory(|m| m.focused());
+ focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id())
+ }
+
+ /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't
+ /// have yet: Undo and Redo keys take it back in the box first.
+ pub(crate) fn page_typing_unapplied(&self) -> bool {
+ self.active.and_then(|i| self.views.get(i)).is_some_and(|v| {
+ v.line_editor.as_ref().is_some_and(|ed| ed.has_unsaved_text()) || v.content.draft.as_ref().is_some_and(|d| d.has_unapplied_typing())
+ })
+ }
+
/// Apply any edit a view queued this frame (organize toolbar, keys).
/// Returns false when a field refused the typing queued for it (its editor is open again).
pub(crate) fn process_pending_edits(&mut self) -> bool {
diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs
index 71ba9ccdac..f9228c1f05 100644
--- a/crates/ui-egui/tests/it/editing.rs
+++ b/crates/ui-egui/tests/it/editing.rs
@@ -5980,3 +5980,58 @@ fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() {
click_at(&mut h, convert);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor");
}
+
+/// The paragraph opened for typing, as a person opens it: a click on its text.
+fn open_paragraph(h: &mut Harness<'static, PdfCraftApp>) {
+ let r = h.state().views[0].page_screen_rect(0).expect("on screen");
+ let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
+ click_at(h, at);
+ assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
+}
+
+/// Ctrl+Z after a formatting change, with the paragraph still open: the change is undone and
+/// the editor closes. The box's own Undo used to take the key, putting back the text it had
+/// before the change into a box laid out for the page after it, and the document kept the change.
+#[test]
+fn undo_with_the_paragraph_open_undoes_the_formatting() {
+ let mut h = harness(1, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ open_paragraph(&mut h);
+ h.get_by_label("Bold").click();
+ h.run_steps(4);
+ {
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!(doc.can_undo(), Some("Edit text"));
+ assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold");
+ assert!(s.views[0].line_editor.is_some(), "still editing");
+ }
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(4);
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!(doc.can_undo(), None, "the formatting is undone");
+ assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica");
+ assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed");
+}
+
+/// Undo while typing takes back the typing in the box first; the document is left alone.
+#[test]
+fn undo_while_typing_takes_back_the_typing_first() {
+ let mut h = harness(1, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ open_paragraph(&mut h);
+ let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
+ h.key_press_modifiers(Modifiers::COMMAND, Key::End);
+ h.event(egui::Event::Text(" more".into()));
+ h.run_steps(2);
+ assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, original);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(3);
+ let s = h.state();
+ let ed = s.views[0].line_editor.as_ref().expect("still typing");
+ assert_eq!(ed.text, original, "the typing is taken back");
+ assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched");
+}
From cac63cf3dbd3449274fd8ad9689cf7778c8c5ef9 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:05:23 +1100
Subject: [PATCH 030/270] fix(ui): Redo after Undo in a page text box puts the
typing back
With typing undone in a paragraph or added text box, its text matches
the page again, so Undo and Redo went to the document. Redo then closed
the box and redid a document change (or nothing), instead of putting the
typing back as the box's own Redo did before. And Shift+Cmd+Z is also
Cmd+Z to the Undo shortcut, so even a Redo the box kept was taken as a
document Undo.
Redo stays with the box while its own history has typing to put back
(the test egui makes when it handles the key), and Shift+Cmd+Z is never
a document Undo.
---
crates/ui-egui/src/commands.rs | 10 ++++++++--
crates/ui-egui/src/editing.rs | 21 ++++++++++++++++++++
crates/ui-egui/tests/it/content.rs | 30 +++++++++++++++++++++++++++++
crates/ui-egui/tests/it/editing.rs | 31 ++++++++++++++++++++++++++++++
4 files changed, 90 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index ee19c168eb..fb4d6f1b6f 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -727,8 +727,14 @@ impl PdfCraftApp {
continue;
}
// A box typed into on the page (a paragraph being edited, added text) keeps Undo and
- // Redo only while it holds typing the page doesn't have; then they reach the document.
- let page_box = matches!(spec.id, "edit.undo" | "edit.redo") && self.page_box_focused(ctx) && !self.page_typing_unapplied();
+ // Redo only while it holds typing the page doesn't have, or Redo has typing to put
+ // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a
+ // Redo the box keeps is never taken as Undo.
+ let page_box = matches!(spec.id, "edit.undo" | "edit.redo")
+ && self.page_box_focused(ctx)
+ && !self.page_typing_unapplied()
+ && !(spec.id == "edit.redo" && self.page_box_can_redo(ctx))
+ && !(spec.id == "edit.undo" && ctx.input(|i| i.modifiers.shift));
if typing && !spec.in_text && !page_box {
continue;
}
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index c10094b208..39e0fd1adf 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -293,6 +293,27 @@ impl PdfCraftApp {
focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id())
}
+ /// Whether the focused box on the page has typing its own Redo would put back: after Undo in
+ /// the box restores what the page has, Redo still belongs to the box, not the document. The
+ /// same test egui makes when it handles the key.
+ pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool {
+ let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false };
+ let focused = ctx.memory(|m| m.focused());
+ let (id, text) = if focused == Some(crate::edit_text_ui::input_id())
+ && let Some(ed) = &view.line_editor
+ {
+ (crate::edit_text_ui::input_id(), &ed.text)
+ } else if focused == Some(crate::content_ui::input_id())
+ && let Some(d) = &view.content.draft
+ {
+ (crate::content_ui::input_id(), &d.text)
+ } else {
+ return false;
+ };
+ egui::TextEdit::load_state(ctx, id)
+ .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.clone()))))
+ }
+
/// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't
/// have yet: Undo and Redo keys take it back in the box first.
pub(crate) fn page_typing_unapplied(&self) -> bool {
diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs
index 515e3e04f1..fa0c01367a 100644
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -861,3 +861,33 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
let pixels = h.render().unwrap();
assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
}
+
+/// The same in an added text box: Redo after Undo puts the typing back in the box, and the
+/// document's own Redo stays waiting.
+#[test]
+fn redo_after_undoing_typing_in_added_text_puts_it_back() {
+ let mut h = harness();
+ let rect = [40.0, 195.0, 240.0, 210.0];
+ let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() }));
+ let bold = pdfcraft_engine::AddedText { bold: true, ..text };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) }));
+ assert!(h.state_mut().execute("edit.undo"));
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ let point = at(&h, 45.0, 195.0);
+ click(&mut h, point);
+ assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text");
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End);
+ h.event(egui::Event::Text(" twice".into()));
+ h.run_steps(2);
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
+ h.run_steps(3);
+ assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"));
+ h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z);
+ h.run_steps(3);
+ assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts the typing back");
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits");
+}
diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs
index f9228c1f05..49e882893d 100644
--- a/crates/ui-egui/tests/it/editing.rs
+++ b/crates/ui-egui/tests/it/editing.rs
@@ -6035,3 +6035,34 @@ fn undo_while_typing_takes_back_the_typing_first() {
assert_eq!(ed.text, original, "the typing is taken back");
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched");
}
+
+/// Redo after Undo in the box puts the typing back, in the box: it doesn't reach the document,
+/// whose own Redo (a formatting change waiting here) must stay waiting.
+#[test]
+fn redo_after_undoing_typing_puts_the_typing_back() {
+ let mut h = harness(1, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ open_paragraph(&mut h);
+ h.get_by_label("Bold").click();
+ h.run_steps(4);
+ // Undo the bold (closing the editor), so the document has it to redo.
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(4);
+ assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text"));
+ open_paragraph(&mut h);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::End);
+ h.event(egui::Event::Text(" more".into()));
+ h.run_steps(2);
+ let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(3);
+ assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, typed, "the typing is taken back");
+ h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
+ h.run_steps(3);
+ let s = h.state();
+ let ed = s.views[0].line_editor.as_ref().expect("still typing");
+ assert_eq!(ed.text, typed, "Redo puts the typing back");
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits");
+}
From 188341c66730ccf99082e03544688c371a316bb6 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:55:09 +1100
Subject: [PATCH 031/270] test(ui): the Redo test checks Undo restores the
exact paragraph text
---
crates/ui-egui/tests/it/editing.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs
index 49e882893d..4bb74f82cd 100644
--- a/crates/ui-egui/tests/it/editing.rs
+++ b/crates/ui-egui/tests/it/editing.rs
@@ -6051,13 +6051,14 @@ fn redo_after_undoing_typing_puts_the_typing_back() {
h.run_steps(4);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text"));
open_paragraph(&mut h);
+ let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
- assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, typed, "the typing is taken back");
+ assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
let s = h.state();
From 6c8fd74ca1c3b1b8c14e02631000990e9c641ac3 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 16:14:01 +1100
Subject: [PATCH 032/270] fix(ui): an unchanged added-text box closes without
an edit
Finishing a box trimmed its text before comparing it with the item, so
an unchanged box whose text was saved with trailing spaces finished as
an edit. Before an Undo or Redo step that edit was applied, clearing
the document's Redo. A box matching its item is now no edit.
---
crates/ui-egui/src/content_ui.rs | 4 ++++
crates/ui-egui/tests/it/content.rs | 26 ++++++++++++++++++++++++++
2 files changed, 30 insertions(+)
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 892e94ed67..788ba49600 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -639,6 +639,10 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option {
Some(i) => {
let (_, a) = on_page(added, d.page).into_iter().find(|(k, _)| *k == i)?;
let AddedContent::Text(old) = &a.content else { return None };
+ // Unchanged is no edit, even for text saved with trailing spaces the trim would drop.
+ if *old == (AddedText { text: d.text.clone(), rect: d.rect, ..d.style.clone() }) {
+ return None;
+ }
let updated = AddedText { text, rect: d.rect, ..d.style };
(*old != updated).then_some(Edit::UpdateContent { page: d.page, index: i, content: AddedContent::Text(updated) })
}
diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs
index fa0c01367a..c24501dab3 100644
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -891,3 +891,29 @@ fn redo_after_undoing_typing_in_added_text_puts_it_back() {
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits");
}
+
+/// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when
+/// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear
+/// that Redo first.
+#[test]
+fn redo_with_an_unchanged_box_open_redoes_the_document() {
+ let mut h = harness();
+ let rect = [40.0, 195.0, 240.0, 210.0];
+ let text = pdfcraft_engine::AddedText { rect, text: "Reviewed ".into(), ..Default::default() };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
+ let pdfcraft_engine::AddedContent::Text(saved) = added(&h)[0].content.clone() else { panic!() };
+ assert_eq!(saved.text, "Reviewed ", "the trailing space is kept");
+ let bold = pdfcraft_engine::AddedText { bold: true, ..saved };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) }));
+ assert!(h.state_mut().execute("edit.undo"));
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ let point = at(&h, 45.0, 195.0);
+ click(&mut h, point);
+ assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text");
+ h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z);
+ h.run_steps(4);
+ let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() };
+ assert!(now.bold, "the waiting Redo was redone");
+ assert!(h.state().views[0].content.draft.is_none(), "the box closed with the step");
+}
From 54e2895ae6a3b3a9572c1c4e4778232bba80a14e Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:38:46 +1100
Subject: [PATCH 033/270] fix(ui): the added-text editor keeps the box's
alignment
Opening a centred or right-aligned Add text box drew its text at the left
edge, a layout the box never had, and Ctrl+Z inside the box (which goes to
the text, not the document) left it showing that way. The editor now lays
the text out with the box's alignment. Justified text is still shown
left-aligned while editing (TextEdit can't justify).
---
crates/ui-egui/src/content_ui.rs | 8 ++++++++
crates/ui-egui/tests/it/content.rs | 22 ++++++++++++++++++++++
2 files changed, 30 insertions(+)
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 674210529e..f8db4c8ca5 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -560,6 +560,13 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
+ // Laid out as the page draws it, so opening the box doesn't show it realigned.
+ // shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter.
+ let halign = match t.style.align {
+ pdfcraft_engine::TextAlign::Center => egui::Align::Center,
+ pdfcraft_engine::TextAlign::Right => egui::Align::Max,
+ pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min,
+ };
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let resp = ui.add(
@@ -567,6 +574,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
.font(font)
.desired_width(r.width().max(60.0))
.desired_rows(1)
+ .horizontal_align(halign)
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.hint_text(tl!("Type text"))
diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs
index 515e3e04f1..de06d44b15 100644
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -715,6 +715,28 @@ fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() {
}
/// Where a mode bar tab is, by its label.
+/// Opening right-aligned added text to edit it shows it right-aligned, as the page does, not at
+/// the box's left edge, where it never was.
+#[test]
+fn the_text_editor_keeps_the_boxs_alignment() {
+ let _gpu = gpu();
+ let mut h = harness();
+ let rect = [40.0, 195.0, 240.0, 210.0];
+ let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ let point = at(&h, 200.0, 195.0);
+ click(&mut h, point);
+ assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text");
+ h.run_steps(3);
+ let r = item_on_screen(&h, rect);
+ let pixels = h.render().unwrap();
+ let left = ink(&pixels, egui::Rect::from_min_max(r.min, pos2(r.center().x, r.max.y)));
+ let right = ink(&pixels, egui::Rect::from_min_max(pos2(r.center().x, r.min.y), r.max));
+ assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)");
+}
+
fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
}
From dcf33ed1c85e8804d76d21d04222bb4424f27878 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 13:38:28 +1100
Subject: [PATCH 034/270] fix(ui): Ctrl+Z in an open paragraph undoes the
document, not stale text
Ctrl+Z is left to a focused text field, so in the paragraph editor it
went to the box's own Undo history. That history still held the
paragraph's text from before the last formatting change, so Ctrl+Z put
it back into a box laid out for the page after the change, a layout
the page never had, and the document kept the change. The box shared
its id across paragraphs too, so it could put back another paragraph's
text.
The history of the paragraph editor and the added-text box now starts
at what the page has. Undo and Redo keep the keys only while the box
holds typing the page doesn't have; then they reach the document. An
open box is applied (as clicking away applies it) and closed before an
Undo or Redo step, so it never shows a state the document has left;
text it can't apply keeps it open and stops the step.
---
crates/ui-egui/src/commands.rs | 5 ++-
crates/ui-egui/src/content_ui.rs | 23 +++++++++++--
crates/ui-egui/src/edit_text_ui.rs | 29 +++++++++++++++-
crates/ui-egui/src/editing.rs | 54 +++++++++++++++++++++++++++++
crates/ui-egui/tests/it/editing.rs | 55 ++++++++++++++++++++++++++++++
5 files changed, 161 insertions(+), 5 deletions(-)
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 92bc0b360e..ee19c168eb 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -726,7 +726,10 @@ impl PdfCraftApp {
if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) {
continue;
}
- if typing && !spec.in_text {
+ // A box typed into on the page (a paragraph being edited, added text) keeps Undo and
+ // Redo only while it holds typing the page doesn't have; then they reach the document.
+ let page_box = matches!(spec.id, "edit.undo" | "edit.redo") && self.page_box_focused(ctx) && !self.page_typing_unapplied();
+ if typing && !spec.in_text && !page_box {
continue;
}
let Some(key) = Key::from_name(s.key) else { continue };
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index f8db4c8ca5..c99c2cd787 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -66,8 +66,18 @@ pub struct TextDraft {
/// Display space.
pub rect: [f64; 4],
pub text: String,
+ /// The box's formatting; its `text` is what the page has (empty for new text).
pub style: AddedText,
pub focus: bool,
+ /// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]).
+ pub typing_from: Option,
+}
+
+impl TextDraft {
+ /// Typing the page doesn't have yet: Undo takes it back in the box before touching the document.
+ pub(crate) fn has_unapplied_typing(&self) -> bool {
+ self.text != self.style.text
+ }
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -394,7 +404,7 @@ pub(crate) fn page_input(
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]];
style.rect = rect;
- cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true });
+ cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None });
consumed = true;
if typed.is_some() {
view.pending_edit = typed;
@@ -432,7 +442,7 @@ pub(crate) fn page_input(
if let Some(edit) = cv.finish_draft(added) {
view.pending_edit = Some(edit);
}
- cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true });
+ cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None });
cv.selected = Some((page, i));
consumed = true;
return consumed;
@@ -567,6 +577,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
pdfcraft_engine::TextAlign::Right => egui::Align::Max,
pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min,
};
+ let input = input_id();
+ crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let resp = ui.add(
@@ -578,7 +590,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.hint_text(tl!("Type text"))
- .id_salt("added-text-edit"),
+ .id(input),
);
if t.focus {
resp.request_focus();
@@ -641,6 +653,11 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option {
}
}
+/// The added-text box's text field.
+pub(crate) fn input_id() -> egui::Id {
+ egui::Id::new("added-text-input")
+}
+
/// How to use the tools, under the Add content list.
pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) {
ui.label(
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index 3a1ab04367..f2c5c55151 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -256,6 +256,29 @@ pub struct LineEditor {
/// horizontal scale (%), and what they were.
pub extras: Extras,
extras0: Extras,
+ /// The text the box's own Undo history starts from; see [`forget_typing_before`].
+ typing_from: Option,
+}
+
+/// The paragraph editor's text box.
+pub(crate) fn input_id() -> egui::Id {
+ egui::Id::new("edit-text-line-input")
+}
+
+/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown
+/// (they share the id). Once the page has `text` (the box opened on it, or formatting applied it),
+/// that history is cleared: Undo in the box takes back only what was typed since, and then reaches
+/// the document. Kept, it put back text from another paragraph, or the whole paragraph as it was
+/// before a format change, into a box showing the page as it is now.
+pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut Option, text: &str) {
+ if from.as_deref() == Some(text) {
+ return;
+ }
+ if let Some(mut state) = egui::TextEdit::load_state(ctx, id) {
+ state.clear_undoer();
+ egui::TextEdit::store_state(ctx, id, state);
+ }
+ *from = Some(text.to_owned());
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -1199,6 +1222,7 @@ fn open_editor(
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
+ typing_from: None,
});
}
@@ -1668,8 +1692,10 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width);
if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) }
};
+ let input = input_id();
+ forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original);
let output = egui::TextEdit::multiline(&mut ed.text)
- .id(egui::Id::new("edit-text-line-input"))
+ .id(input)
.font(font.clone())
.margin(egui::Margin::ZERO)
.layouter(&mut layouter)
@@ -1801,6 +1827,7 @@ mod tests {
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
+ typing_from: None,
};
(app, ed)
}
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index 2fdc2e9d02..6f16837242 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -229,6 +229,9 @@ impl PdfCraftApp {
fn history_step(&mut self, undo: bool) {
let Some((i, id)) = self.active_ids() else { return };
+ if !self.close_text_editors(i, id) {
+ return;
+ }
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
match result {
Ok(label) => {
@@ -247,6 +250,57 @@ impl PdfCraftApp {
}
}
+ /// Before Undo or Redo: a paragraph or added text box open on the page shows the document as
+ /// it is before the step, so it is applied as clicking away applies it, then closed. Left
+ /// open, it kept showing what the step took away, and wrote from it when it was next applied.
+ /// What was typed is then the step Undo takes back. False (nothing is undone, and the box
+ /// stays open) while it holds text the standard fonts can't draw, or text that couldn't be
+ /// applied: it is shown, never lost.
+ pub(crate) fn close_text_editors(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool {
+ let Some(view) = self.views.get_mut(i) else { return true };
+ if let Some(c) = view.content.blocked() {
+ view.content.hold_blocked();
+ self.notify(crate::content_ui::undrawable_message(c));
+ return false;
+ }
+ if let Some(ed) = view.line_editor.take()
+ && let Some(edit) = crate::edit_text_ui::finished_edit(ed.clone())
+ && !self.apply_edit(edit)
+ {
+ if let Some(view) = self.views.get_mut(i) {
+ view.line_editor = Some(ed);
+ }
+ return false;
+ }
+ let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default();
+ let Some(view) = self.views.get_mut(i) else { return true };
+ let draft = view.content.draft.clone();
+ if let Some(edit) = view.content.finish_draft(&added)
+ && !self.apply_edit(edit)
+ {
+ if let Some(view) = self.views.get_mut(i) {
+ view.content.draft = draft;
+ }
+ return false;
+ }
+ true
+ }
+
+ /// Whether the keyboard is in a box typed into on the page: a paragraph being edited, or added
+ /// text.
+ pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool {
+ let focused = ctx.memory(|m| m.focused());
+ focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id())
+ }
+
+ /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't
+ /// have yet: Undo and Redo keys take it back in the box first.
+ pub(crate) fn page_typing_unapplied(&self) -> bool {
+ self.active.and_then(|i| self.views.get(i)).is_some_and(|v| {
+ v.line_editor.as_ref().is_some_and(|ed| ed.has_unsaved_text()) || v.content.draft.as_ref().is_some_and(|d| d.has_unapplied_typing())
+ })
+ }
+
/// Apply any edit a view queued this frame (organize toolbar, keys).
/// Returns false when a field refused the typing queued for it (its editor is open again).
pub(crate) fn process_pending_edits(&mut self) -> bool {
diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs
index 73464d33e7..2fcf67dc42 100644
--- a/crates/ui-egui/tests/it/editing.rs
+++ b/crates/ui-egui/tests/it/editing.rs
@@ -6019,3 +6019,58 @@ fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() {
click_at(&mut h, convert);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor");
}
+
+/// The paragraph opened for typing, as a person opens it: a click on its text.
+fn open_paragraph(h: &mut Harness<'static, PdfCraftApp>) {
+ let r = h.state().views[0].page_screen_rect(0).expect("on screen");
+ let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
+ click_at(h, at);
+ assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
+}
+
+/// Ctrl+Z after a formatting change, with the paragraph still open: the change is undone and
+/// the editor closes. The box's own Undo used to take the key, putting back the text it had
+/// before the change into a box laid out for the page after it, and the document kept the change.
+#[test]
+fn undo_with_the_paragraph_open_undoes_the_formatting() {
+ let mut h = harness(1, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ open_paragraph(&mut h);
+ h.get_by_label("Bold").click();
+ h.run_steps(4);
+ {
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!(doc.can_undo(), Some("Edit text"));
+ assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold");
+ assert!(s.views[0].line_editor.is_some(), "still editing");
+ }
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(4);
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!(doc.can_undo(), None, "the formatting is undone");
+ assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica");
+ assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed");
+}
+
+/// Undo while typing takes back the typing in the box first; the document is left alone.
+#[test]
+fn undo_while_typing_takes_back_the_typing_first() {
+ let mut h = harness(1, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ open_paragraph(&mut h);
+ let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
+ h.key_press_modifiers(Modifiers::COMMAND, Key::End);
+ h.event(egui::Event::Text(" more".into()));
+ h.run_steps(2);
+ assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, original);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(3);
+ let s = h.state();
+ let ed = s.views[0].line_editor.as_ref().expect("still typing");
+ assert_eq!(ed.text, original, "the typing is taken back");
+ assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched");
+}
From 5c1c3462eb429deab889b9552b2b35821d950004 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 13:40:02 +1100
Subject: [PATCH 035/270] M7: align the selected lines of a paragraph or text
box on their own
Format text's alignment always re-aligned the whole paragraph or added
text box, and clicking it closed an added text box, losing the
selection, so one line couldn't be aligned. With text selected,
alignment now applies to the lines (text between line breaks) the
selection touches; with only the caret, to the whole box as before.
- Added text stores each line's alignment in its /PCAdded parameters
(/Aligns, null where a line follows /Align). A justified line's last
row is not stretched.
- An edited paragraph sends its rows' alignments with each rewrite
while it is open (BlockStyle::aligns), in both the single-style and
the styled-region rewrite.
- Both editors draw each line by its own alignment, and the alignment
buttons show the selected line's. An added text box stays open while
Format text is used, its style applied at once; clicks are judged by
where they were pressed, so a touch or pen tap on the panel doesn't
close either editor.
- Automation: `aligns` on page_add_text, content_update and text_edit.
---
crates/automation/src/content.rs | 51 +++++++--
crates/automation/src/lib.rs | 11 +-
crates/automation/src/tools.rs | 3 +
crates/automation/tests/it/automation.rs | 23 ++++
crates/edit/src/added.rs | 136 +++++++++++++++--------
crates/edit/src/added/arabic_tests.rs | 4 +-
crates/edit/src/lib.rs | 1 +
crates/edit/src/paragraph_align.rs | 135 ++++++++++++++++++++++
crates/edit/src/tests.rs | 71 ++++++++++++
crates/edit/src/text.rs | 44 +++++---
crates/edit/src/text_regions.rs | 15 ++-
crates/engine/src/lib.rs | 1 +
crates/engine/src/tests.rs | 2 +
crates/ui-egui/src/content_ui.rs | 117 +++++++++++++++----
crates/ui-egui/src/edit_text_ui.rs | 39 ++++++-
crates/ui-egui/src/format_text_ui.rs | 60 +++++++++-
crates/ui-egui/src/panels.rs | 62 +++++++++--
crates/ui-egui/tests/it/content.rs | 32 ++++++
crates/ui-egui/tests/it/editing.rs | 53 +++++++++
parity/acrobat-features.toml | 4 +-
20 files changed, 738 insertions(+), 126 deletions(-)
create mode 100644 crates/edit/src/paragraph_align.rs
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
index 8926a1368b..c162e739df 100644
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -53,17 +53,48 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
t.color = parse_color(c)?;
}
if let Some(al) = a.opt_str("align")? {
- t.align = match al {
- "left" => TextAlign::Left,
- "center" | "centre" => TextAlign::Center,
- "right" => TextAlign::Right,
- "justify" => TextAlign::Justify,
- other => return Err(bad(format!("unknown align {other:?}"))),
- };
+ t.align = parse_align(al)?;
+ // The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one.
+ t.aligns.clear();
+ }
+ if let Some(aligns) = opt_aligns(a)? {
+ let count = pdfcraft_engine::paragraph_align::count(&t.text);
+ t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align);
}
Ok(())
}
+pub(crate) fn parse_align(al: &str) -> Result {
+ Ok(match al {
+ "left" => TextAlign::Left,
+ "center" | "centre" => TextAlign::Center,
+ "right" => TextAlign::Right,
+ "justify" => TextAlign::Justify,
+ other => return Err(bad(format!("unknown align {other:?}"))),
+ })
+}
+
+/// Most entries `aligns` takes: one per paragraph, and no text has this many.
+const MAX_ALIGNS: usize = 4096;
+
+/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`.
+pub(crate) fn opt_aligns(a: &Args) -> Result>>> {
+ let Some(v) = a.get("aligns") else { return Ok(None) };
+ let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
+ if items.len() > MAX_ALIGNS {
+ return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
+ }
+ items
+ .iter()
+ .map(|v| match v {
+ Value::Null => Ok(None),
+ Value::String(s) => parse_align(s).map(Some),
+ _ => Err(bad("each entry of aligns is left, center, right, justify or null")),
+ })
+ .collect::>>()
+ .map(Some)
+}
+
impl Automation {
pub(crate) fn content_list(&self, a: &Args) -> Result {
let doc = self.doc(a)?;
@@ -153,6 +184,8 @@ impl Automation {
match &mut content {
AddedContent::Text(t) => {
if let Some(s) = a.opt_str("text")? {
+ // Each paragraph keeps its own alignment through the retyping.
+ t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns);
t.text = s.to_string();
}
style(a, t)?;
@@ -164,8 +197,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
- if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) {
- return Err(bad("font, size, bold, italic, color and align apply to text"));
+ if ["font", "size", "bold", "italic", "color", "align", "aligns"].iter().any(|k| a.get(k).is_some()) {
+ return Err(bad("font, size, bold, italic, color, align and aligns apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 1dae412e41..8c5ae96bc3 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -726,13 +726,10 @@ impl Automation {
style.color = Some(comments::parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
- style.align = Some(match al {
- "left" => pdfcraft_engine::TextAlign::Left,
- "center" => pdfcraft_engine::TextAlign::Center,
- "right" => pdfcraft_engine::TextAlign::Right,
- "justify" => pdfcraft_engine::TextAlign::Justify,
- other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
- });
+ style.align = Some(content::parse_align(al)?);
+ }
+ if let Some(aligns) = content::opt_aligns(&a)? {
+ style.aligns = aligns;
}
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 1e009c1170..db521074d7 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -594,6 +594,7 @@ pub fn tools() -> Vec {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
+ "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
}),
&["doc", "page", "text"],
)),
@@ -615,6 +616,7 @@ pub fn tools() -> Vec {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
+ "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 },
"image": { "type": "string", "description": "Replace the picture with this file." },
@@ -1362,6 +1364,7 @@ pub fn tools() -> Vec {
"size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#rrggbb" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
+ "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"underline": { "type": "boolean" },
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index 9fd5c4269a..5b8931c6af 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -8424,3 +8424,26 @@ fn bookmark_list_reports_colour_and_style() {
let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved");
}
+
+/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box
+/// again. Text edits take `aligns` for a paragraph's rows the same way.
+#[test]
+fn added_text_lines_take_their_own_alignments() {
+ let dir = workdir("paragraph-aligns");
+ let mut a = auto(&dir);
+ let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
+ ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
+ let rect = |a: &mut Automation, text: &str| -> Vec {
+ let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
+ let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
+ line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
+ };
+ assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
+ assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
+ // The whole box centred: the right-aligned line goes with it.
+ ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
+ let right = rect(&mut a, "Right line");
+ assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
+ assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
+ assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
+}
diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs
index d39bb0fa91..29ccc4e8c9 100644
--- a/crates/edit/src/added.rs
+++ b/crates/edit/src/added.rs
@@ -116,6 +116,16 @@ pub struct AddedText {
pub size: f64,
pub color: [f64; 3],
pub align: Align,
+ /// Paragraphs (lines typed with Enter) aligned other than [`AddedText::align`], by index;
+ /// `None` and paragraphs past the end follow `align` ([`crate::paragraph_align`]).
+ pub aligns: Vec>,
+}
+
+impl AddedText {
+ /// Paragraph `i`'s alignment.
+ pub fn paragraph_align(&self, i: usize) -> Align {
+ crate::paragraph_align::align_of(&self.aligns, i, self.align)
+ }
}
impl Default for AddedText {
@@ -129,6 +139,7 @@ impl Default for AddedText {
size: 12.0,
color: [0.0; 3],
align: Align::Left,
+ aligns: Vec::new(),
}
}
}
@@ -232,7 +243,7 @@ fn norm(r: [f64; 4]) -> [f64; 4] {
/// The lines of a text item after wrapping to its box width.
pub fn lines(t: &AddedText) -> Vec {
- wrapped(t).into_iter().map(|(line, _)| line).collect()
+ wrapped(t).into_iter().map(|(line, ..)| line).collect()
}
#[cfg(test)]
@@ -242,13 +253,13 @@ thread_local! {
}
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
-/// does).
-fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
+/// does) and which paragraph it is in.
+fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
#[cfg(test)]
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = (t.rect[2] - t.rect[0]).max(t.size);
let mut out = Vec::new();
- for para in t.text.split('\n') {
+ for (index, para) in t.text.split('\n').enumerate() {
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
let thai = para.chars().any(is_thai);
@@ -256,7 +267,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
&& !rtl_script
&& let Ok(lines) = thai_lines(t, para, width)
{
- out.extend(lines.into_iter().map(|line| (line, false)));
+ out.extend(lines.into_iter().map(|line| (line, false, index)));
continue;
}
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
@@ -272,7 +283,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
let candidate_w =
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
if !line.is_empty() && candidate_w > width {
- out.push((std::mem::take(&mut line), rtl));
+ out.push((std::mem::take(&mut line), rtl, index));
line = word.to_string();
line_w = measure(word);
} else {
@@ -280,7 +291,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
line_w = candidate_w;
}
}
- out.push((line, rtl));
+ out.push((line, rtl, index));
}
out
}
@@ -354,7 +365,7 @@ pub fn first_undrawable(t: &AddedText) -> Option {
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
}
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
- wrapped(t).into_iter().find_map(|(line, rtl)| {
+ wrapped(t).into_iter().find_map(|(line, rtl, _)| {
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
pieces.iter().find_map(|p| match p {
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
@@ -523,8 +534,26 @@ fn font_name(base: &str) -> String {
const GLYPHS_PER_FONT: usize = 240;
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
-/// its pieces in drawing order and its width at the item's size.
-type LaidLine = (String, bool, Vec, f64);
+/// its paragraph, its pieces in drawing order and its width at the item's size.
+type LaidLine = (String, bool, usize, Vec, f64);
+
+/// Where line `i` (`w` wide unstretched, with `spaces` spaces) starts in the box `r`, and its word
+/// spacing. `paragraphs` holds each line's paragraph: a line takes its paragraph's alignment, and
+/// every line of a justified paragraph but its last is stretched to the box's width.
+fn placed(t: &AddedText, r: [f64; 4], paragraphs: &[usize], i: usize, w: f64, spaces: usize, rtl: bool) -> (f64, f64) {
+ let paragraph = paragraphs.get(i).copied().unwrap_or(0);
+ let align = t.paragraph_align(paragraph);
+ let ends_paragraph = paragraphs.get(i + 1) != Some(¶graph);
+ let tw = if align == Align::Justify && !ends_paragraph && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
+ let x = match align {
+ // A right-to-left paragraph's unstretched last line ends at the right edge.
+ Align::Justify if rtl => r[2] - w - tw * spaces as f64,
+ Align::Left | Align::Justify => r[0],
+ Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
+ Align::Right => r[2] - w,
+ };
+ (x, tw)
+}
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
@@ -532,7 +561,7 @@ type LaidLine = (String, bool, Vec, f64);
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> {
let laid = wrapped(t)
.into_iter()
- .map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
+ .map(|(line, rtl, paragraph)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, paragraph, pieces, w)))
.collect::, _>>()?;
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
}
@@ -554,7 +583,7 @@ fn draw_laid_shaped(
// one of the fonts, so copy and search get each character exactly once.
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap = HashMap::new();
- for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
+ for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p {
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
@@ -596,21 +625,14 @@ fn draw_laid_shaped(
}
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
- for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
+ let paragraphs: Vec = laid.iter().map(|(_, _, p, ..)| *p).collect();
+ for (i, (line, rtl, _, pieces, w)) in laid.iter().enumerate() {
// Shaped glyphs are painted in visual order. Keep the original line's typing order
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
out.extend(format!("/Span <>> BDC\n").bytes());
let spaces = line.matches(' ').count();
- // Justified: the spaces of every line but the last stretch to fill the box.
- let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
- let x = match t.align {
- // A right-to-left paragraph's unstretched last line ends at the right edge.
- Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
- Align::Left | Align::Justify => r[0],
- Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
- Align::Right => r[2] - w,
- };
+ let (x, tw) = placed(t, r, ¶graphs, i, *w, spaces, *rtl);
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
let mut pen = x;
let mut current: Option<&str> = None;
@@ -788,20 +810,14 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
let r = text_rect(t);
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
- let all = lines(t);
- for (i, line) in all.iter().enumerate() {
+ let all = wrapped(t);
+ let paragraphs: Vec = all.iter().map(|(_, _, p)| *p).collect();
+ for (i, (line, ..)) in all.iter().enumerate() {
let w = t.family.width(line, t.size, t.bold, t.italic);
- let x = match t.align {
- Align::Left | Align::Justify => r[0],
- Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
- Align::Right => r[2] - w,
- };
// Baseline: 0.8 em below the line top.
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
- // Justified: word spacing makes every line but the last fill the box.
- let spaces = line.matches(' ').count();
- let tw =
- if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
+ // Justified: word spacing makes every line of a paragraph but its last fill the box.
+ let (x, tw) = placed(t, r, ¶graphs, i, w, line.matches(' ').count(), false);
out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes());
out.extend(literal(&win_ansi(line)));
out.extend_from_slice(b" Tj\n");
@@ -851,15 +867,12 @@ fn params(c: &Content) -> Dict {
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
d.set(b"Size".to_vec(), Object::Real(t.size));
d.set(b"Color".to_vec(), arr(&t.color));
- d.set(
- b"Align".to_vec(),
- Object::Int(match t.align {
- Align::Left => 0,
- Align::Center => 1,
- Align::Right => 2,
- Align::Justify => 3,
- }),
- );
+ d.set(b"Align".to_vec(), Object::Int(align_code(t.align)));
+ // Each paragraph's own alignment, or null where it follows /Align.
+ let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align);
+ if !aligns.is_empty() {
+ d.set(b"Aligns".to_vec(), Object::Array(aligns.iter().map(|a| a.map_or(Object::Null, |a| Object::Int(align_code(a)))).collect()));
+ }
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
}
Content::Image(i) => {
@@ -883,6 +896,24 @@ fn params(c: &Content) -> Dict {
d
}
+fn align_code(a: Align) -> i64 {
+ match a {
+ Align::Left => 0,
+ Align::Center => 1,
+ Align::Right => 2,
+ Align::Justify => 3,
+ }
+}
+
+fn align_from(code: Option) -> Align {
+ match code {
+ Some(1) => Align::Center,
+ Some(2) => Align::Right,
+ Some(3) => Align::Justify,
+ _ => Align::Left,
+ }
+}
+
fn parse(doc: &Document, d: &Dict) -> Option {
let r = nums(doc, d.get(b"Rect"));
let rect: [f64; 4] = r.try_into().ok()?;
@@ -890,20 +921,27 @@ fn parse(doc: &Document, d: &Dict) -> Option {
b"Text" => {
let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));
let c = nums(doc, d.get(b"Color"));
+ let text = d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
+ let align = align_from(d.int(b"Align"));
+ // At most one entry per paragraph, whatever the file says.
+ let aligns = d
+ .get(b"Aligns")
+ .and_then(|a| {
+ doc.resolve(a)
+ .as_array()
+ .map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect())
+ })
+ .unwrap_or_default();
Some(Content::Text(AddedText {
rect,
- text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(),
+ text,
family,
bold,
italic,
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
- align: match d.int(b"Align") {
- Some(1) => Align::Center,
- Some(2) => Align::Right,
- Some(3) => Align::Justify,
- _ => Align::Left,
- },
+ align,
+ aligns: crate::paragraph_align::tidy(aligns, align),
}))
}
b"Image" => {
diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs
index d4ebb73e42..0671e3ce32 100644
--- a/crates/edit/src/added/arabic_tests.rs
+++ b/crates/edit/src/added/arabic_tests.rs
@@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster {
fn line(clusters: Vec, rtl: bool) -> LaidLine {
let text = clusters.iter().map(|c| c.text.as_str()).collect();
let width = clusters.iter().map(|c| c.advance).sum::() * item().size;
- (text, rtl, vec![Piece::Shaped(clusters)], width)
+ (text, rtl, 0, vec![Piece::Shaped(clusters)], width)
}
/// A glyph outline: a box on the baseline, `advance` em wide.
@@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let mixed: LaidLine =
- ("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
+ ("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
let laid = vec![mixed, rtl];
let mut doc = Document::new_empty();
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index 630f12550b..f367b39d85 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -1031,6 +1031,7 @@ pub use images::{ImageChange, PageImage, change_image, page_images, reading_imag
pub mod bidi;
pub mod indic;
mod linebreak;
+pub mod paragraph_align;
pub mod text;
pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
diff --git a/crates/edit/src/paragraph_align.rs b/crates/edit/src/paragraph_align.rs
new file mode 100644
index 0000000000..cf2023e615
--- /dev/null
+++ b/crates/edit/src/paragraph_align.rs
@@ -0,0 +1,135 @@
+//! Alignment paragraph by paragraph. A paragraph is the text between two hard line breaks
+//! (`\n`): a line typed with Enter in added text, or a source row of an edited paragraph. Each can
+//! have an alignment of its own; `None` (or no entry) follows the box's or paragraph's alignment.
+
+use std::ops::RangeInclusive;
+
+use crate::added::Align;
+
+/// How many paragraphs `text` has (an empty text has one).
+pub fn count(text: &str) -> usize {
+ text.bytes().filter(|b| *b == b'\n').count().saturating_add(1)
+}
+
+/// The paragraph the byte position `at` of `text` is in.
+fn paragraph_at(text: &str, at: usize) -> usize {
+ let bytes = text.as_bytes();
+ bytes.get(..at.min(bytes.len())).unwrap_or_default().iter().filter(|b| **b == b'\n').count()
+}
+
+/// The paragraphs a selection (byte range) of `text` touches. An empty selection is the caret's
+/// paragraph; a selection ending just after a line break doesn't reach the next paragraph.
+pub fn selected(text: &str, range: std::ops::Range) -> RangeInclusive {
+ let first = paragraph_at(text, range.start);
+ let last = if range.end > range.start { paragraph_at(text, range.end - 1) } else { first };
+ first..=last.max(first)
+}
+
+/// Paragraph `i`'s alignment: its own, or `base`.
+pub fn align_of(aligns: &[Option], i: usize, base: Align) -> Align {
+ aligns.get(i).copied().flatten().unwrap_or(base)
+}
+
+/// Give paragraphs `which` of a text with `count` paragraphs the alignment `align`; the others
+/// keep theirs. With a `base`, entries equal to it become `None` ([`tidy`]), so equal alignments
+/// compare equal however they were set; without one (the base is only guessed, as for an edited
+/// paragraph's own rows) every entry set stays. Trailing `None`s are dropped either way.
+pub fn set(aligns: &[Option], count: usize, which: RangeInclusive, align: Align, base: Option) -> Vec> {
+ let mut out: Vec > = (0..count).map(|i| aligns.get(i).copied().flatten()).collect();
+ for i in which {
+ if let Some(a) = out.get_mut(i) {
+ *a = Some(align);
+ }
+ }
+ match base {
+ Some(base) => tidy(out, base),
+ None => trimmed(out),
+ }
+}
+
+/// `aligns` without entries that only repeat `base`, and without trailing `None`s.
+pub fn tidy(mut aligns: Vec >, base: Align) -> Vec > {
+ for a in &mut aligns {
+ if *a == Some(base) {
+ *a = None;
+ }
+ }
+ trimmed(aligns)
+}
+
+fn trimmed(mut aligns: Vec >) -> Vec > {
+ while aligns.last() == Some(&None) {
+ aligns.pop();
+ }
+ aligns
+}
+
+/// Carry the alignments of `old`'s paragraphs over to `new`: the paragraphs before and after
+/// the change keep theirs, and the paragraphs the change made take the alignment of the first
+/// paragraph it changed, or for lines only inserted, of the paragraph before them (so Enter in or
+/// after a centred paragraph makes centred ones).
+pub fn remap(old: &str, new: &str, aligns: &[Option]) -> Vec> {
+ if aligns.is_empty() || old == new {
+ return aligns.to_vec();
+ }
+ let (o, n): (Vec<&str>, Vec<&str>) = (old.split('\n').collect(), new.split('\n').collect());
+ let shortest = o.len().min(n.len());
+ let prefix = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest);
+ let suffix = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest - prefix);
+ let at = |i: usize| aligns.get(i).copied().flatten();
+ let inserted_only = o.len() - prefix - suffix == 0;
+ let changed = if inserted_only && prefix > 0 { at(prefix - 1) } else { at(prefix) };
+ let mut out: Vec > = (0..prefix).map(at).collect();
+ out.extend(std::iter::repeat_n(changed, n.len() - prefix - suffix));
+ out.extend((o.len() - suffix..o.len()).map(at));
+ trimmed(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use Align::*;
+
+ #[test]
+ fn a_selection_names_the_paragraphs_it_touches() {
+ let text = "One\nTwo\nThree";
+ assert_eq!(count(text), 3);
+ assert_eq!(count(""), 1);
+ assert_eq!(selected(text, 0..0), 0..=0, "the caret's paragraph");
+ assert_eq!(selected(text, 5..6), 1..=1);
+ assert_eq!(selected(text, 2..9), 0..=2);
+ assert_eq!(selected(text, 4..8), 1..=1, "a whole line with its break stays on that line");
+ assert_eq!(selected(text, 0..999), 0..=2, "past the end is clamped");
+ // Not a character boundary: still counted by bytes, never a panic.
+ assert_eq!(selected("é\nb", 1..1), 0..=0);
+ }
+
+ #[test]
+ fn setting_one_paragraph_keeps_the_others_and_tidies() {
+ let one = set(&[], 3, 1..=1, Center, Some(Left));
+ assert_eq!(one, [None, Some(Center)]);
+ assert_eq!(align_of(&one, 0, Left), Left);
+ assert_eq!(align_of(&one, 1, Left), Center);
+ assert_eq!(align_of(&one, 9, Right), Right);
+ assert_eq!(set(&one, 3, 1..=1, Left, Some(Left)), [], "back to the box's alignment is no entry at all");
+ assert_eq!(set(&one, 3, 2..=7, Right, Some(Left)), [None, Some(Center), Some(Right)], "paragraphs past the end are ignored");
+ assert_eq!(set(&[Some(Right); 9], 2, 0..=0, Center, Some(Left)), [Some(Center), Some(Right)], "entries past the text are dropped");
+ assert_eq!(set(&[], 3, 0..=0, Left, None), [Some(Left)], "without a base, an explicit entry stays");
+ }
+
+ #[test]
+ fn alignments_follow_their_paragraphs_through_an_edit() {
+ let aligns = [Some(Center), None, Some(Right)];
+ assert_eq!(remap("A\nB\nC", "A\nB\nC", &aligns), aligns);
+ // A line inserted before the last keeps the last right-aligned.
+ assert_eq!(remap("A\nB\nC", "A\nB\nnew\nC", &aligns), [Some(Center), None, None, Some(Right)]);
+ // Enter inside the centred first paragraph: both halves are centred.
+ assert_eq!(remap("Ab\nB\nC", "A\nb\nB\nC", &aligns), [Some(Center), Some(Center), None, Some(Right)]);
+ // The middle paragraph deleted.
+ assert_eq!(remap("A\nB\nC", "A\nC", &aligns), [Some(Center), Some(Right)]);
+ // Everything replaced takes the first changed paragraph's alignment.
+ assert_eq!(remap("A\nB", "x", &[None, Some(Right)]), []);
+ assert_eq!(remap("A\nA", "A", &[Some(Center), Some(Right)]), [Some(Center)]);
+ assert_eq!(remap("A", "A\nA", &[Some(Center)]), [Some(Center), Some(Center)]);
+ }
+}
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 0549906dbe..692a21c8c2 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -6638,3 +6638,74 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() {
assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_))));
assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content");
}
+
+/// One row of an edited paragraph aligned on its own (#A: highlighting a line and aligning it):
+/// the other rows stay where they were.
+#[test]
+fn one_row_of_a_paragraph_takes_its_own_alignment() {
+ let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
+ let block = text::text_blocks(&doc, 0).unwrap()[0].clone();
+ assert_eq!(block.editor_text, "One two three four five six seven\neight nine ten eleven twelve");
+ let style = text::BlockStyle { aligns: vec![None, Some(added::Align::Right)], ..Default::default() };
+ text::rewrite_block(&mut doc, 0, 0, Some(&block.editor_text), &style).unwrap();
+ let lines = text::text_lines(&doc, 0).unwrap();
+ let row = |t: &str| lines.iter().find(|l| l.text.starts_with(t)).unwrap().rect;
+ assert!((row("One")[0] - block.rect[0]).abs() < 0.5, "the first row stays at the left: {:?}", row("One"));
+ assert!((row("eight")[2] - block.rect[2]).abs() < 1.0, "the second row ends at the right edge: {:?} vs {}", row("eight"), block.rect[2]);
+}
+
+/// The same in a region of several styles: only the short first row is centred.
+#[test]
+fn one_row_of_a_styled_region_takes_its_own_alignment() {
+ let mut doc = mixed_region_page();
+ let block = text::text_blocks(&doc, 0).unwrap()[1].clone();
+ assert_eq!(
+ block.editor_text,
+ "About
+Plain bold text.
+Italic footer"
+ );
+ let row = |lines: &[text::TextLine], t: &str| lines.iter().find(|l| l.text.starts_with(t)).unwrap().rect;
+ let before = text::text_lines(&doc, 0).unwrap();
+ let style = text::BlockStyle { aligns: vec![Some(added::Align::Center)], ..Default::default() };
+ text::rewrite_block(&mut doc, 0, 1, Some(&block.editor_text), &style).unwrap();
+ let lines = text::text_lines(&doc, 0).unwrap();
+ assert!((row(&lines, "Italic")[0] - row(&before, "Italic")[0]).abs() < 0.5, "the last row stays put");
+ assert!((row(&lines, "Plain")[0] - row(&before, "Plain")[0]).abs() < 0.5, "the middle row stays put");
+ let about = row(&lines, "About");
+ let (mid, centre) = ((about[0] + about[2]) / 2.0, (block.rect[0] + block.rect[2]) / 2.0);
+ assert!(about[0] > row(&before, "About")[0] + 5.0 && (mid - centre).abs() < 1.5, "the first row is centred: {mid} vs {centre}");
+}
+
+/// Added text: each paragraph is laid out with its own alignment, a justified paragraph's last
+/// line isn't stretched, and the alignments survive saving and reopening.
+#[test]
+fn added_text_paragraphs_keep_their_own_alignments() {
+ let mut doc = fixture();
+ let text = AddedText {
+ rect: [72.0, 600.0, 372.0, 700.0],
+ text: "Left line\nRight line\nCentred line".into(),
+ size: 12.0,
+ aligns: vec![None, Some(Align::Right), Some(Align::Center)],
+ ..AddedText::default()
+ };
+ assert_eq!(text.paragraph_align(1), Align::Right);
+ add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
+ let doc = reopen(&doc);
+ let Content::Text(t) = &list_added(&doc)[0].content else { panic!() };
+ assert_eq!(t.aligns, text.aligns, "saved with the item");
+ let page = streams(&doc, 0).join("\n");
+ let x_of = |s: &str| -> f64 {
+ let at = page.find(&format!("({s}) Tj")).unwrap();
+ let tm = page[..at].rfind(" Tm").unwrap();
+ let nums: Vec = page[..tm].rsplit("1 0 0 1 ").next().unwrap().split_whitespace().filter_map(|v| v.parse().ok()).collect();
+ nums[0]
+ };
+ let w = |s: &str| Family::Helvetica.width(s, 12.0, false, false);
+ assert!((x_of("Left line") - 72.0).abs() < 0.01);
+ assert!((x_of("Right line") - (372.0 - w("Right line"))).abs() < 0.01);
+ assert!((x_of("Centred line") - (72.0 + (300.0 - w("Centred line")) / 2.0)).abs() < 0.01);
+ // A paragraph's own alignment the same as the box's is no entry at all.
+ let tidy = paragraph_align::tidy(vec![Some(Align::Left), Some(Align::Right)], Align::Left);
+ assert_eq!(tidy, [None, Some(Align::Right)]);
+}
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index d003935314..3ce185a985 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -2565,10 +2565,10 @@ fn paragraph_separator(c: char) -> bool {
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
-/// those pieces join without a space.
-fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec {
+/// those pieces join without a space. Each line comes with the `\n`-separated row it is from.
+fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, usize)> {
let mut out = Vec::new();
- for row in text.split('\n') {
+ for (index, row) in text.split('\n').enumerate() {
let mut line = String::new();
let start = out.len();
for word in row.split(paragraph_separator).filter(|word| !word.is_empty()) {
@@ -2579,7 +2579,7 @@ fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec {
(false, _) => format!("{line}{piece}"),
};
if !line.is_empty() && advance(&candidate) > width {
- out.push(std::mem::take(&mut line));
+ out.push((std::mem::take(&mut line), index));
line = piece.to_string();
} else {
line = candidate;
@@ -2587,7 +2587,7 @@ fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec {
}
}
if !line.is_empty() || out.len() == start {
- out.push(line);
+ out.push((line, index));
}
}
out
@@ -2616,6 +2616,9 @@ pub struct BlockStyle {
/// Fill colour (RGB 0–1).
pub color: Option<[f64; 3]>,
pub align: Option,
+ /// Rows (the text's lines between `\n`s) aligned other than the paragraph, by index; `None`
+ /// and rows past the end follow `align` ([`crate::paragraph_align`]).
+ pub aligns: Vec>,
/// Draw a line under each line of text.
pub underline: Option,
/// Line spacing as a multiple of the font size (1.2 is ordinary).
@@ -2926,8 +2929,10 @@ fn rewrite_block_from(
}
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
+ // Each row's alignment: its own, or the paragraph's.
+ let align_of = |row: usize| style.aligns.get(row).copied().flatten().or(align);
// Alignment: each line's offset from the left edge, in text space.
- let offset = |line: &str| -> f64 {
+ let offset = |line: &str, align: Option| -> f64 {
let free = (width - advance(line)).max(0.0) / k;
match align {
Some(crate::added::Align::Center) => free / 2.0,
@@ -2943,7 +2948,6 @@ fn rewrite_block_from(
// (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a
// generated Type 3 font, a subset without a space) the extra space follows each space as a
// TJ adjustment instead, and code 32, which may then be a letter, gets none.
- let justify = align == Some(crate::added::Align::Justify);
let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) {
Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))),
None => type3.is_none(),
@@ -2951,12 +2955,14 @@ fn rewrite_block_from(
let mut x = 0.0;
let mut tw_set = 0.0;
let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space
- for (i, line) in wrapped.iter().enumerate() {
+ for (i, (line, row)) in wrapped.iter().enumerate() {
+ let line_align = align_of(*row);
+ let justify = line_align == Some(crate::added::Align::Justify);
let spaces = line.matches(' ').count();
let tw =
if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 };
// A stretched line fills the width from the left edge.
- let dx = if tw > 0.0 { 0.0 } else { offset(line) };
+ let dx = if tw > 0.0 { 0.0 } else { offset(line, line_align) };
if i > 0 || dx != 0.0 {
block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })]));
}
@@ -3349,24 +3355,28 @@ mod tests {
assert!(!same_base_font("Arial", ""));
}
+ fn rows(lines: Vec<(String, usize)>) -> Vec {
+ lines.into_iter().map(|(line, _)| line).collect()
+ }
+
#[test]
fn paragraph_wrapping_keeps_ideographic_spaces() {
let width = |s: &str| s.chars().count() as f64;
- assert_eq!(wrap("A B C", 4.0, width), ["A B", "C"]);
- assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]);
- assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
- assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]);
+ assert_eq!(rows(wrap("A B C", 4.0, width)), ["A B", "C"]);
+ assert_eq!(rows(wrap("A\u{a0}B C", 3.0, width)), ["A B", "C"]);
+ assert_eq!(rows(wrap("A\u{2028}B C", 3.0, width)), ["A B", "C"]);
+ assert_eq!(rows(wrap(" \tA B\r\nC ", 3.0, width)), ["A B", "C"]);
}
#[test]
fn japanese_text_wraps_between_characters_but_not_before_closing_marks() {
let width = |s: &str| s.chars().count() as f64;
- assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]);
+ assert_eq!(rows(wrap("日本語の文章です。", 3.0, width)), ["日本語", "の文章", "です。"]);
// The comma can't begin a row, so the character before it moves down with it.
- assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]);
+ assert_eq!(rows(wrap("あいう、えお", 3.0, width)), ["あい", "う、え", "お"]);
// Pieces of one word join without a space; separate words keep theirs.
- assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]);
- assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]);
+ assert_eq!(rows(wrap("Edit PDFを編集", 6.0, width)), ["Edit", "PDFを編集"]);
+ assert_eq!(rows(wrap("一行目 二行目", 4.0, width)), ["一行目 ", "二行目"]);
}
#[test]
diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs
index a775594944..11ae6c5a2c 100644
--- a/crates/edit/src/text_regions.rs
+++ b/crates/edit/src/text_regions.rs
@@ -728,6 +728,9 @@ pub(super) fn rewrite_styled_block(
let mut rows: Vec> = Vec::new();
// Each row's characters, as indexes into the draft's characters (`chars` holds all of them).
let mut row_ranges = Vec::new();
+ // Each row's line in the draft (its `\n`-separated part), which [`BlockStyle::aligns`] counts.
+ let mut row_lines: Vec = Vec::new();
+ let mut draft_line = 0usize;
let mut row_start = 0;
let mut row = Vec::new();
let mut row_width = 0.0;
@@ -737,6 +740,8 @@ pub(super) fn rewrite_styled_block(
let Some(item) = chars.get(at) else { break };
if item.0 == '\n' {
row_ranges.push(row_start..row_start + row.len());
+ row_lines.push(draft_line);
+ draft_line = draft_line.saturating_add(1);
rows.push(std::mem::take(&mut row));
row_width = 0.0;
at += 1;
@@ -760,6 +765,7 @@ pub(super) fn rewrite_styled_block(
row.pop();
}
row_ranges.push(row_start..row_start + row.len());
+ row_lines.push(draft_line);
rows.push(std::mem::take(&mut row));
row_width = 0.0;
row_start = at;
@@ -770,6 +776,7 @@ pub(super) fn rewrite_styled_block(
}
if !row.is_empty() {
row_ranges.push(row_start..row_start + row.len());
+ row_lines.push(draft_line);
rows.push(row);
}
let streams = content_streams(doc, &p.dict)?;
@@ -869,10 +876,12 @@ pub(super) fn rewrite_styled_block(
continue;
}
let w = measured.get(row_index).copied().unwrap_or(0.0);
- let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
+ // The row's own alignment, or the paragraph's.
+ let asked = row_lines.get(row_index).and_then(|l| style.aligns.get(*l)).copied().flatten().or(style.align);
+ let align = asked.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
- let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] };
- let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width };
+ let left = if style.width.is_none() && asked.is_none() { original_rect[0] } else { block.rect[0] };
+ let available = if style.width.is_none() && asked.is_none() { original_rect[2] - original_rect[0] } else { width };
let mut x = left
+ match align {
crate::added::Align::Center => (available - w) / 2.0,
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 68fc076b97..caeddb02ac 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -56,6 +56,7 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width};
+pub use pdfcraft_edit::paragraph_align;
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ed4e6efa1c..7e2542c5d5 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -6369,6 +6369,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() {
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
+ aligns: Vec::new(),
};
session.apply(id, Edit::AddText { page: 1, text }).unwrap();
let after = session.get(id).unwrap();
@@ -6408,6 +6409,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
+ aligns: Vec::new(),
},
},
Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] },
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index c99c2cd787..c9d2c33aba 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -66,9 +66,12 @@ pub struct TextDraft {
/// Display space.
pub rect: [f64; 4],
pub text: String,
- /// The box's formatting; its `text` is what the page has (empty for new text).
+ /// The box's formatting; its `text` and `rect` are what the page has (empty for new text), and
+ /// its `aligns` follow `text` as it is typed.
pub style: AddedText,
pub focus: bool,
+ /// The selected bytes of `text` (empty: the caret), for aligning the lines it touches.
+ pub selection: Option>,
/// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]).
pub typing_from: Option,
}
@@ -404,7 +407,7 @@ pub(crate) fn page_input(
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]];
style.rect = rect;
- cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None });
+ cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, selection: None, typing_from: None });
consumed = true;
if typed.is_some() {
view.pending_edit = typed;
@@ -442,7 +445,16 @@ pub(crate) fn page_input(
if let Some(edit) = cv.finish_draft(added) {
view.pending_edit = Some(edit);
}
- cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None });
+ cv.draft = Some(TextDraft {
+ page,
+ index: Some(i),
+ rect: t.rect,
+ text: t.text.clone(),
+ style: t.clone(),
+ focus: true,
+ selection: None,
+ typing_from: None,
+ });
cv.selected = Some((page, i));
consumed = true;
return consumed;
@@ -552,6 +564,11 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
let (mut commit, mut cancel, mut done) = (false, false, false);
let mut undrawable = None;
let memo = &view.content.undrawable;
+ // Clicks outside the document (the Format text panel) keep the text open, as they keep a
+ // paragraph being edited open.
+ // Where the click that took the focus was: the pointer itself may be gone by then (a touch, a
+ // pen lifted away).
+ let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
// Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over
// the page, so only where the document shows: text scrolled under the mode bar or a side
// panel must leave them their pointer (#2755).
@@ -570,28 +587,38 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
- // Laid out as the page draws it, so opening the box doesn't show it realigned.
- // shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter.
- let halign = match t.style.align {
- pdfcraft_engine::TextAlign::Center => egui::Align::Center,
- pdfcraft_engine::TextAlign::Right => egui::Align::Max,
- pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min,
- };
+ let color = Color32::from_rgb(cr, cg, cb);
+ // Laid out as the page draws it, each paragraph with its own alignment, so opening the
+ // box doesn't show it realigned.
+ let style = t.style.clone();
+ let line_font = font.clone();
+ let mut layouter =
+ |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| aligned_galley(ui, text.as_str(), &style, &line_font, color, wrap);
+ let before = t.text.clone();
let input = input_id();
crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
- let resp = ui.add(
- egui::TextEdit::multiline(&mut t.text)
- .font(font)
- .desired_width(r.width().max(60.0))
- .desired_rows(1)
- .horizontal_align(halign)
- .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
- .text_color(Color32::from_rgb(cr, cg, cb))
- .hint_text(tl!("Type text"))
- .id(input),
- );
+ let output = egui::TextEdit::multiline(&mut t.text)
+ .font(font)
+ .desired_width(r.width().max(60.0))
+ .desired_rows(1)
+ .layouter(&mut layouter)
+ .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
+ .text_color(color)
+ .hint_text(tl!("Type text"))
+ .id(input)
+ .show(ui);
+ let resp = output.response;
+ if let Some(cursor) = output.cursor_range {
+ let chars = cursor.as_sorted_char_range();
+ let byte = |index: usize| t.text.char_indices().nth(index).map_or(t.text.len(), |(at, _)| at);
+ t.selection = Some(byte(chars.start.into())..byte(chars.end.into()));
+ }
+ // Each paragraph keeps its own alignment through the typing.
+ if t.text != before {
+ t.style.aligns = pdfcraft_engine::paragraph_align::remap(&before, &t.text, &t.style.aligns);
+ }
if t.focus {
resp.request_focus();
t.focus = false;
@@ -613,7 +640,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// Pressing a button takes the focus from the text: that is not a click away.
let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p));
cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
- commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor;
+ commit = done || resp.lost_focus() && !on_buttons && !outside && !view.objects.hold_editor;
});
// Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
undrawable = self::undrawable(t, memo);
@@ -637,6 +664,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
/// updated, and text emptied by retyping removed. Empty new text adds nothing.
fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option {
let text = d.text.trim_end().to_string();
+ let aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns);
+ let d = TextDraft { style: AddedText { aligns, ..d.style }, ..d };
match d.index {
None if text.trim().is_empty() => None,
None => {
@@ -658,6 +687,50 @@ pub(crate) fn input_id() -> egui::Id {
egui::Id::new("added-text-input")
}
+/// `text` laid out as added text with `style` is drawn: each paragraph (line typed with Enter) wrapped
+/// to `wrap` and placed by its own alignment, a justified one with every row but its last
+/// stretched.
+fn aligned_galley(ui: &egui::Ui, text: &str, style: &AddedText, font: &egui::FontId, color: Color32, wrap: f32) -> std::sync::Arc {
+ use pdfcraft_engine::TextAlign;
+ let job = |text: &str, align: TextAlign| {
+ let mut job = egui::text::LayoutJob::simple(text.to_owned(), font.clone(), color, wrap);
+ job.justify = align == TextAlign::Justify;
+ job
+ };
+ let uniform = text.split('\n').enumerate().all(|(i, _)| style.paragraph_align(i) == style.align);
+ if uniform && style.align != TextAlign::Center && style.align != TextAlign::Right {
+ return ui.fonts_mut(|f| f.layout_job(job(text, style.align)));
+ }
+ // Each paragraph on its own (egui aligns a whole job one way), its rows moved within the
+ // box's width, then joined in order: the caret and selection see one text.
+ let mut galleys = Vec::new();
+ let mut start = 0;
+ for (i, paragraph) in text.split('\n').enumerate() {
+ let end = start + paragraph.len();
+ // The last paragraph keeps a trailing break, as egui's own split does.
+ let last = end + 1 == text.len();
+ let piece = if last { text.get(start..).unwrap_or_default() } else { paragraph };
+ let mut galley = ui.fonts_mut(|f| f.layout_job(job(piece, style.paragraph_align(i))));
+ let g = std::sync::Arc::make_mut(&mut galley);
+ for placed in &mut g.rows {
+ let free = (wrap - placed.row.size.x).max(0.0);
+ placed.pos.x = match style.paragraph_align(i) {
+ TextAlign::Center => free / 2.0,
+ TextAlign::Right => free,
+ TextAlign::Left | TextAlign::Justify => 0.0,
+ };
+ }
+ g.rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(g.rect.width().max(wrap), g.rect.height()));
+ galleys.push(galley);
+ if last {
+ break;
+ }
+ start = end + 1;
+ }
+ let whole = std::sync::Arc::new(egui::text::LayoutJob::simple(text.to_owned(), font.clone(), color, wrap));
+ std::sync::Arc::new(egui::Galley::concat(whole, &galleys, ui.ctx().pixels_per_point()))
+}
+
/// How to use the tools, under the Add content list.
pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) {
ui.label(
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index f2c5c55151..f9074af30f 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -356,7 +356,12 @@ impl LineEditor {
family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)),
size: (l.size != o.size).then_some(l.size),
color: (l.color != o.color).then_some(l.color),
- align: (l.align != o.align).then_some(l.align),
+ // The whole paragraph's alignment when it was picked, or when the rows' own were
+ // dropped for it (the rows are where those put them, so leaving it out keeps them there).
+ align: (l.align != o.align || l.aligns.is_empty() && !o.aligns.is_empty()).then_some(l.align),
+ // Rows aligned on their own, sent with every rewrite of the paragraph while it is open:
+ // nothing in the page records them, so a rewrite without them would undo them.
+ aligns: if l.aligns != o.aligns || self.text != self.original { l.aligns.clone() } else { Vec::new() },
underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline),
line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0)
.then_some(self.extras.line_spacing),
@@ -403,9 +408,17 @@ impl LineEditor {
self.source_rect = block.rect.map(|v| v as f32);
self.multiline = block.rows.len() > 1;
self.source = Some((block.clone(), lines));
- self.text = block.editor_text.clone();
+ let before = std::mem::replace(&mut self.text, block.editor_text.clone());
+ self.follow_rows(&before);
self.original = self.text.clone();
}
+
+ /// The text changed from `before`: rows aligned on their own keep their alignment.
+ pub(crate) fn follow_rows(&mut self, before: &str) {
+ for look in [&mut self.look, &mut self.look0] {
+ look.aligns = pdfcraft_engine::paragraph_align::remap(before, &self.text, &look.aligns);
+ }
+ }
}
/// The look shown for a paragraph: the family and weight guessed from its PDF font name.
@@ -1539,7 +1552,14 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
let g = Arc::make_mut(&mut galley);
let mut bounds = Rect::NOTHING;
let mut meshes = Rect::NOTHING;
+ // Which line of the text (between `\n`s) each row is on: rows aligned on their own go where
+ // the rewrite will put them, in the paragraph's width.
+ let mut line = 0usize;
for (index, placed) in g.rows.iter_mut().enumerate() {
+ let own = ed.look.aligns.get(line).copied().flatten();
+ if placed.ends_with_newline {
+ line = line.saturating_add(1);
+ }
let (last_baseline, last_rect) = sources.last().copied().unwrap_or((first_baseline, first_rect));
let (baseline, source_rect) = sources
.get(index)
@@ -1552,7 +1572,14 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
let base_y = (block.rect[3] - baseline) as f32 * scale;
let row = Arc::make_mut(&mut placed.row);
placed.pos.y = base_y - ascent;
- placed.pos.x = if centered { (width - row.size.x) / 2.0 } else { (source_rect[0] - block.rect[0]) as f32 * scale };
+ let free = (width - row.size.x).max(0.0);
+ placed.pos.x = match own {
+ Some(pdfcraft_engine::TextAlign::Center) => free / 2.0,
+ Some(pdfcraft_engine::TextAlign::Right) => free,
+ Some(pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify) => 0.0,
+ None if centered => (width - row.size.x) / 2.0,
+ None => (source_rect[0] - block.rect[0]) as f32 * scale,
+ };
for glyph in &mut row.glyphs {
let dy = ascent - glyph.pos.y;
glyph.pos.y = ascent;
@@ -1578,7 +1605,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
/// The inline editor; returns the edit once the text is applied.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option {
// Clicks outside the document (the Format text panel) keep the paragraph open.
- let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
+ // Where the click that took the focus was: the pointer itself may be gone by then (a touch, a
+ // pen lifted away).
+ let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
let page = view.line_editor.as_ref()?.page;
let xf = view.page_xform(page)?;
let hold = view.objects.hold_editor;
@@ -1692,6 +1721,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width);
if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) }
};
+ let before = ed.text.clone();
let input = input_id();
forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original);
let output = egui::TextEdit::multiline(&mut ed.text)
@@ -1719,6 +1749,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
}
let r = output.response;
if r.changed() {
+ ed.follow_rows(&before);
// The next pass measures the updated draft with the Apply/Save rewrite.
ctx.request_repaint();
}
diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs
index 749f09dcc2..6071b76b67 100644
--- a/crates/ui-egui/src/format_text_ui.rs
+++ b/crates/ui-egui/src/format_text_ui.rs
@@ -39,6 +39,44 @@ pub(crate) struct Fonts<'a> {
pub shown: String,
}
+/// Text being typed in, for the alignment buttons: with some of it selected, they show and set the
+/// alignment of the paragraphs (lines between `\n`s) the selection touches; with none, the box's.
+pub(crate) struct Paragraphs<'a> {
+ pub text: &'a str,
+ /// A byte range of `text`; empty or `None` is nothing selected.
+ pub selection: Option>,
+ /// The box's own alignment is known (added text), so a paragraph set to it keeps no entry of its
+ /// own. An edited paragraph's alignment is only guessed from where its rows sit: its rows keep
+ /// every alignment set.
+ pub tidy: bool,
+}
+
+impl Paragraphs<'_> {
+ /// The paragraphs a selection touches, or `None` with nothing selected.
+ fn selected(&self) -> Option> {
+ self.selection.clone().filter(|r| !r.is_empty()).map(|r| pdfcraft_engine::paragraph_align::selected(self.text, r))
+ }
+
+ /// The alignment the buttons show: the first selected paragraph's, or the box's.
+ fn shown(&self, look: &AddedText) -> TextAlign {
+ self.selected().map_or(look.align, |which| look.paragraph_align(*which.start()))
+ }
+
+ /// `align` picked: for the selected paragraphs, or the whole box (dropping theirs).
+ fn choose(&self, look: &mut AddedText, align: TextAlign) {
+ match self.selected() {
+ Some(which) => {
+ let count = pdfcraft_engine::paragraph_align::count(self.text);
+ look.aligns = pdfcraft_engine::paragraph_align::set(&look.aligns, count, which, align, self.tidy.then_some(look.align));
+ }
+ None => {
+ look.align = align;
+ look.aligns.clear();
+ }
+ }
+ }
+}
+
/// What the panel changed: the style (in place) and a font picked from the list.
#[derive(Default)]
pub(crate) struct Outcome {
@@ -70,7 +108,14 @@ fn number(v: f64) -> String {
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
-pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome {
+pub(crate) fn panel(
+ ui: &mut egui::Ui,
+ t: &Tokens,
+ look: &mut AddedText,
+ extras: Option<&mut Extras>,
+ fonts: Fonts,
+ paragraphs: Option,
+) -> Outcome {
let before = (look.clone(), extras.as_deref().copied());
let mut out = Outcome::default();
widgets::section_title(ui, tl!("Format text"));
@@ -148,7 +193,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
ui.painter().vline(sep.center().x, sep.y_range().shrink(3.0), Stroke::new(1.0, t.divider));
planned(ui, "list", tl!("Bulleted list"));
planned(ui, "list-ordered", tl!("Numbered list"));
- let current = match look.align {
+ let shown = paragraphs.as_ref().map_or(look.align, |p| p.shown(look));
+ let current = match shown {
TextAlign::Left => "align-left",
TextAlign::Center => "align-center",
TextAlign::Right => "align-right",
@@ -164,8 +210,14 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
(TextAlign::Right, "align-right", tl!("Align right")),
(TextAlign::Justify, "align-justify", tl!("Justify")),
] {
- if icons::button(ui, icon, 30.0, look.align == a, tip).clicked() {
- look.align = a;
+ if icons::button(ui, icon, 30.0, shown == a, tip).clicked() {
+ match ¶graphs {
+ Some(p) => p.choose(look, a),
+ None => {
+ look.align = a;
+ look.aligns.clear();
+ }
+ }
ui.close();
}
}
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 7d16bc3407..4285f1da31 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -534,7 +534,9 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
.map(|f| (f.resource, f.face))
.collect();
let shown = ed.font_shown(&sources);
- let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown });
+ // A selection aligns the rows it touches; the caret alone, the whole paragraph.
+ let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false };
+ let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }, Some(paragraphs));
let mut changed = out.changed;
match out.font {
Some(FontChoice::KeepSource) => {
@@ -561,6 +563,21 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
return;
}
+ // Typing added text: the box's formatting, and alignment for the lines a selection touches.
+ if let Some(i) = app.active_ids().map(|(i, _)| i)
+ && let Some(draft) = app.views[i].content.draft.clone()
+ {
+ if draft.index.is_none() && app.quick_tool == crate::QuickTool::AddText {
+ crate::content_ui::hint(ui, t);
+ }
+ let mut style = draft.style.clone();
+ let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true };
+ added_text_format(ui, t, &mut style, Some(paragraphs));
+ if style != draft.style {
+ app.restyle_draft(i, style);
+ }
+ return;
+ }
let image = app.active_ids().and_then(|(i, id)| {
let (page, index) = app.views[i].content.selected?;
let doc = app.session.get(id)?;
@@ -593,34 +610,65 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
match selected {
Some((page, index, text)) => {
let mut style = text.clone();
- added_text_format(ui, t, &mut style);
+ added_text_format(ui, t, &mut style, None);
if style != text {
// The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later.
if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) {
- app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style };
+ app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], aligns: Vec::new(), ..style };
}
}
}
None if app.quick_tool == crate::QuickTool::AddText => {
crate::content_ui::hint(ui, t);
let mut style = app.text_style.clone();
- added_text_format(ui, t, &mut style);
+ added_text_format(ui, t, &mut style, None);
app.text_style = style;
}
None => {}
}
}
-/// Format text for added text: the standard families, size, colour, style and alignment.
-fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) {
+/// Format text for added text: the standard families, size, colour, style and alignment
+/// (of the lines `paragraphs` selects, while the text is being typed).
+fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option) {
let shown = style.family.label().to_string();
- let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown });
+ let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }, paragraphs);
if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {
style.family = family;
}
}
impl PdfCraftApp {
+ /// Format text changed while added text is being typed in view `index`: the box takes the new
+ /// style at once and stays open. Text already on the page is updated with what has been typed,
+ /// as one undoable change; new text waits until it is finished. Text typed after this styling
+ /// starts from it too.
+ pub(crate) fn restyle_draft(&mut self, index: usize, style: pdfcraft_engine::AddedText) {
+ let Some(view) = self.views.get_mut(index) else { return };
+ // Text the standard fonts can't draw stays in the box, unwritten, until it is corrected (#125).
+ let blocked = view.content.blocked().is_some();
+ let Some(d) = view.content.draft.as_mut() else { return };
+ let committed = d.style.clone();
+ // `text` and `rect` stay what the page has; the rest is the new style.
+ d.style = pdfcraft_engine::AddedText { text: committed.text.clone(), rect: committed.rect, ..style };
+ d.focus = true;
+ self.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], aligns: Vec::new(), ..d.style.clone() };
+ let (page, item) = (d.page, d.index);
+ let text = d.text.trim_end().to_string();
+ let Some(item) = item.filter(|_| !text.trim().is_empty() && !blocked) else { return };
+ let aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns);
+ let updated = pdfcraft_engine::AddedText { text, rect: d.rect, aligns, ..d.style.clone() };
+ if updated == committed {
+ return;
+ }
+ if self.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index: item, content: pdfcraft_engine::AddedContent::Text(updated.clone()) })
+ && let Some(d) = self.views.get_mut(index).and_then(|v| v.content.draft.as_mut())
+ {
+ // What the page has now. The aligns stay those of the text in the box.
+ d.style = pdfcraft_engine::AddedText { aligns: std::mem::take(&mut d.style.aligns), ..updated };
+ }
+ }
+
/// Apply the open paragraph's formatting as one change; the paragraph stays open.
pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) {
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/it/content.rs b/crates/ui-egui/tests/it/content.rs
index de06d44b15..0476064618 100644
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -883,3 +883,35 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
let pixels = h.render().unwrap();
assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
}
+
+/// Bug A for added text: with one line of the box selected, an alignment applies to that line
+/// only, the box stays open, and Undo puts it back and closes the box.
+#[test]
+fn aligning_a_selected_line_of_added_text_aligns_only_that_line() {
+ let mut h = harness();
+ let rect = [40.0, 195.0, 240.0, 220.0];
+ let text = pdfcraft_engine::AddedText { rect, text: "Line one\nLine two".into(), ..Default::default() };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ let point = at(&h, 45.0, 195.0);
+ click(&mut h, point);
+ assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text");
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End);
+ h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Home);
+ h.run_steps(2);
+ let button = h.get_by_label("Alignment").rect().center();
+ click(&mut h, button);
+ let item = h.get_by_label("Align right").rect().center();
+ click(&mut h, item);
+ let aligns = |h: &Harness<'static, PdfCraftApp>| match &added(h)[0].content {
+ pdfcraft_engine::AddedContent::Text(t) => (t.align, t.aligns.clone()),
+ _ => panic!(),
+ };
+ assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)]));
+ assert!(h.state().views[0].content.draft.is_some(), "the box stays open");
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
+ h.run_steps(4);
+ assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![]), "Undo puts it back");
+ assert!(h.state().views[0].content.draft.is_none(), "and closes the box");
+}
diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs
index 2fcf67dc42..d51f7f551f 100644
--- a/crates/ui-egui/tests/it/editing.rs
+++ b/crates/ui-egui/tests/it/editing.rs
@@ -6074,3 +6074,56 @@ fn undo_while_typing_takes_back_the_typing_first() {
assert_eq!(ed.text, original, "the typing is taken back");
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched");
}
+
+/// A paragraph of a short, a wide and a short row, opened in the paragraph editor.
+fn three_row_paragraph() -> Harness<'static, PdfCraftApp> {
+ let bytes = paragraph_drag_fixture("BT /F1 10 Tf 12 TL 60 400 Td (One two) Tj T* (three four five six seven eight) Tj T* (nine ten) Tj ET", 0, 1);
+ let mut h = paragraph_drag_harness(bytes, 0);
+ let (at, _) = paragraph_drag_points(&h, "One two three four five six seven eight nine ten", [0.0, 0.0]);
+ click_at(&mut h, at);
+ assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
+ h
+}
+
+/// Format text's alignment flyout, by pointer.
+fn align_by_pointer(h: &mut Harness<'static, PdfCraftApp>, choice: &str) {
+ let button = h.get_by_label("Alignment").rect().center();
+ click_at(h, button);
+ let item = h.get_by_label(choice).rect().center();
+ click_at(h, item);
+}
+
+fn row_rect(h: &Harness<'static, PdfCraftApp>, starts: &str) -> [f64; 4] {
+ let s = h.state();
+ s.session.get(s.views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text.starts_with(starts)).expect("the row").rect
+}
+
+/// Bug A: with one row of a paragraph selected, an alignment applies to that row only.
+/// Bug C: Undo then puts the paragraph back and closes the editor, instead of leaving it open on
+/// a text the page no longer has.
+#[test]
+fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() {
+ let mut h = three_row_paragraph();
+ let first = row_rect(&h, "One");
+ let wide = row_rect(&h, "three");
+ let last = row_rect(&h, "nine");
+ // Select the last row.
+ h.key_press_modifiers(Modifiers::COMMAND, Key::End);
+ h.key_press_modifiers(Modifiers::SHIFT, Key::Home);
+ h.run_steps(2);
+ align_by_pointer(&mut h, "Align right");
+ {
+ let s = h.state();
+ assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit text"));
+ assert!(s.views[0].line_editor.is_some(), "the paragraph stays open");
+ }
+ let moved = row_rect(&h, "nine");
+ assert!((row_rect(&h, "One")[0] - first[0]).abs() < 0.5, "the first row stays put");
+ assert!((moved[2] - wide[2]).abs() < 1.0 && moved[0] > last[0] + 20.0, "the last row is right-aligned: {moved:?}");
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(4);
+ let s = h.state();
+ assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the alignment is undone");
+ assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed");
+ assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back");
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 4af0cd98c2..f0deaf5b16 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -1962,8 +1962,8 @@ 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/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/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. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581)"
+evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/edit/src/tests.rs::one_row_of_a_paragraph_takes_its_own_alignment", "crates/edit/src/tests.rs::one_row_of_a_styled_region_takes_its_own_alignment", "crates/edit/src/tests.rs::added_text_paragraphs_keep_their_own_alignments", "crates/ui-egui/tests/it/editing.rs::aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back", "crates/ui-egui/tests/it/content.rs::aligning_a_selected_line_of_added_text_aligns_only_that_line", "crates/automation/tests/it/automation.rs::added_text_lines_take_their_own_alignments"]
+notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581). With text selected, Format text aligns only the lines (rows between line breaks) the selection touches; with only the caret, the whole paragraph or box (aligns in text_edit, page_add_text and content_update). Added text keeps each line's alignment in its /PCAdded parameters, and a justified line's last row is not stretched. Missing: an edited paragraph's per-row alignment is not recorded in the page, so after it is closed and reopened a later rewrite of it can put that row back to the paragraph's alignment (as whole-paragraph justify is lost, #2508/#2515); aligning a row can regroup the paragraph, and the editor then shows only the part it groups first; the added-text editor stretches justified lines with egui's own spacing, close to but not exactly as written"
[[feature]]
id = "edit.line-paragraph-spacing"
From 1f29575d34cda035b52997c14e31253ff8dd7f3e Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 12:42:12 +1100
Subject: [PATCH 036/270] M7: align a one-row paragraph across the column it
sits in
Centring one highlighted line of a paragraph moves it out of the
paragraph's rows, so the next alignment of it saw a one-row paragraph
and aligned it in its own box: Left left it where it was, and since a
single row grows to the page's margin, each Centre moved it further
right. An alignment for a one-row paragraph is now laid out across the
column of text it sits in (the left edge the page's other text uses, to
the furthest right those rows reach), or its own box when no column
holds it.
---
crates/edit/src/tests.rs | 33 +++++++++++++++++++++++++++++++++
crates/edit/src/text.rs | 12 +++++++++++-
crates/edit/src/text_regions.rs | 25 +++++++++++++++++++++++++
3 files changed, 69 insertions(+), 1 deletion(-)
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 692a21c8c2..ecac4c782c 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -6709,3 +6709,36 @@ fn added_text_paragraphs_keep_their_own_alignments() {
let tidy = paragraph_align::tidy(vec![Some(Align::Left), Some(Align::Right)], Align::Left);
assert_eq!(tidy, [None, Some(Align::Right)]);
}
+
+/// A one-row title over a body column: an alignment picked for it is measured across the column.
+/// Left puts it on the column's left edge, and centring it again leaves it where it is, rather
+/// than aligning it in its own box (a no-op) or toward the page's margin (moving it right each
+/// time). It is aligned as the editor aligns a selected row, in whichever paragraph holds it.
+#[test]
+fn a_lone_row_aligns_across_the_column_it_sits_in() {
+ let mut doc = text_page(
+ "BT /F1 24 Tf 1 0 0 1 250 700 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET",
+ );
+ let blocks = text::text_blocks(&doc, 0).unwrap();
+ assert_eq!(blocks[0].text, "Title");
+ let column = [blocks[1].rect[0], blocks[1].rect[2]];
+ let title = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect;
+ let align = |doc: &mut Document, a: added::Align| {
+ let lines = text::text_lines(doc, 0).unwrap();
+ let line = lines.iter().position(|l| l.text == "Title").unwrap();
+ let blocks = text::text_blocks(doc, 0).unwrap();
+ let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.lines.contains(&line)).unwrap();
+ // The title is the paragraph's first row.
+ let style = text::BlockStyle { aligns: vec![Some(a)], ..Default::default() };
+ text::rewrite_block(doc, 0, index, Some(&block.editor_text), &style).unwrap();
+ };
+ align(&mut doc, added::Align::Center);
+ let centred = title(&doc);
+ assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[1]) / 2.0).abs() < 1.0, "centred in the column: {centred:?} vs {column:?}");
+ align(&mut doc, added::Align::Center);
+ assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc));
+ align(&mut doc, added::Align::Left);
+ assert!((title(&doc)[0] - column[0]).abs() < 0.5, "left: back on the column's edge, {:?} vs {column:?}", title(&doc));
+ align(&mut doc, added::Align::Right);
+ assert!((title(&doc)[2] - column[1]).abs() < 1.0, "right: on the column's right edge, {:?} vs {column:?}", title(&doc));
+}
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index 3ce185a985..e8e80d29f5 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -24,7 +24,7 @@ use crate::EditError;
mod regions;
#[path = "text_session.rs"]
mod session;
-use regions::{group_blocks, is_centred, rewrite_styled_block};
+use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block};
pub use session::TextSession;
/// One line of existing text.
@@ -2678,6 +2678,16 @@ fn rewrite_block_from(
) -> Result {
let blocks = group_blocks(&lines);
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
+ // An alignment for a one-row paragraph is laid out across the column it sits in, as a
+ // paragraph of that width moved to its left edge.
+ let framed;
+ let style = match alignment_frame(&lines, &b, style) {
+ Some([left, right]) => {
+ framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() };
+ &framed
+ }
+ None => style,
+ };
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
// One clipping run is enough: rewriting the paragraph would drop its mask with the rest.
let mut dropped = coincident_ops(&lines, &members);
diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs
index 11ae6c5a2c..d9465cdffd 100644
--- a/crates/edit/src/text_regions.rs
+++ b/crates/edit/src/text_regions.rs
@@ -44,6 +44,31 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option {
Some(best as f64 / 4.0)
}
+/// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of
+/// text it sits in (the left edge the rest of the page's text uses, to the furthest right those
+/// rows reach) when that column holds it, otherwise its own box. A lone row's own box is no frame
+/// to align in: left-aligning a title centred on the page left it where it was, and since a single
+/// row grows to the page's margin, centring it again moved it right each time. `None` when no
+/// alignment is asked for, or when the paragraph is several rows, turned, or given a width or a
+/// move of its own.
+pub(super) fn alignment_frame(lines: &[TextLine], block: &TextBlock, style: &BlockStyle) -> Option<[f64; 2]> {
+ let asked = style.align.is_some() || style.aligns.iter().any(Option::is_some);
+ if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 {
+ return None;
+ }
+ let own = [block.rect[0], block.rect[2]];
+ let tolerance = block.size.max(1.0) * 0.5;
+ let Some(left) = neighbour_left(lines, block) else { return Some(own) };
+ let right = lines
+ .iter()
+ .enumerate()
+ .filter(|(i, line)| !block.lines.contains(i) && (line.rect[0] - left).abs() < tolerance)
+ .map(|(_, line)| line.rect[2])
+ .fold(f64::NEG_INFINITY, f64::max);
+ let holds = right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance;
+ Some(if holds { [left, right] } else { own })
+}
+
/// Whether a block reads as centred, and so must keep its centre when it grows or wraps.
///
/// Several ragged rows whose left edges disagree while their centres agree are centred. A one row
From 5c61312d81157e9092fa8aadebf8fa7122d57713 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:05:23 +1100
Subject: [PATCH 037/270] fix(ui): Redo after Undo in a page text box puts the
typing back
With typing undone in a paragraph or added text box, its text matches
the page again, so Undo and Redo went to the document. Redo then closed
the box and redid a document change (or nothing), instead of putting the
typing back as the box's own Redo did before. And Shift+Cmd+Z is also
Cmd+Z to the Undo shortcut, so even a Redo the box kept was taken as a
document Undo.
Redo stays with the box while its own history has typing to put back
(the test egui makes when it handles the key), and Shift+Cmd+Z is never
a document Undo.
---
crates/ui-egui/src/commands.rs | 10 ++++++++--
crates/ui-egui/src/editing.rs | 21 ++++++++++++++++++++
crates/ui-egui/tests/it/content.rs | 30 +++++++++++++++++++++++++++++
crates/ui-egui/tests/it/editing.rs | 31 ++++++++++++++++++++++++++++++
4 files changed, 90 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index ee19c168eb..fb4d6f1b6f 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -727,8 +727,14 @@ impl PdfCraftApp {
continue;
}
// A box typed into on the page (a paragraph being edited, added text) keeps Undo and
- // Redo only while it holds typing the page doesn't have; then they reach the document.
- let page_box = matches!(spec.id, "edit.undo" | "edit.redo") && self.page_box_focused(ctx) && !self.page_typing_unapplied();
+ // Redo only while it holds typing the page doesn't have, or Redo has typing to put
+ // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a
+ // Redo the box keeps is never taken as Undo.
+ let page_box = matches!(spec.id, "edit.undo" | "edit.redo")
+ && self.page_box_focused(ctx)
+ && !self.page_typing_unapplied()
+ && !(spec.id == "edit.redo" && self.page_box_can_redo(ctx))
+ && !(spec.id == "edit.undo" && ctx.input(|i| i.modifiers.shift));
if typing && !spec.in_text && !page_box {
continue;
}
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index 6f16837242..2b31091084 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -293,6 +293,27 @@ impl PdfCraftApp {
focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id())
}
+ /// Whether the focused box on the page has typing its own Redo would put back: after Undo in
+ /// the box restores what the page has, Redo still belongs to the box, not the document. The
+ /// same test egui makes when it handles the key.
+ pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool {
+ let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false };
+ let focused = ctx.memory(|m| m.focused());
+ let (id, text) = if focused == Some(crate::edit_text_ui::input_id())
+ && let Some(ed) = &view.line_editor
+ {
+ (crate::edit_text_ui::input_id(), &ed.text)
+ } else if focused == Some(crate::content_ui::input_id())
+ && let Some(d) = &view.content.draft
+ {
+ (crate::content_ui::input_id(), &d.text)
+ } else {
+ return false;
+ };
+ egui::TextEdit::load_state(ctx, id)
+ .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.clone()))))
+ }
+
/// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't
/// have yet: Undo and Redo keys take it back in the box first.
pub(crate) fn page_typing_unapplied(&self) -> bool {
diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs
index 0476064618..7edb13ef6a 100644
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -915,3 +915,33 @@ fn aligning_a_selected_line_of_added_text_aligns_only_that_line() {
assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![]), "Undo puts it back");
assert!(h.state().views[0].content.draft.is_none(), "and closes the box");
}
+
+/// The same in an added text box: Redo after Undo puts the typing back in the box, and the
+/// document's own Redo stays waiting.
+#[test]
+fn redo_after_undoing_typing_in_added_text_puts_it_back() {
+ let mut h = harness();
+ let rect = [40.0, 195.0, 240.0, 210.0];
+ let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() }));
+ let bold = pdfcraft_engine::AddedText { bold: true, ..text };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) }));
+ assert!(h.state_mut().execute("edit.undo"));
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ let point = at(&h, 45.0, 195.0);
+ click(&mut h, point);
+ assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text");
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End);
+ h.event(egui::Event::Text(" twice".into()));
+ h.run_steps(2);
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
+ h.run_steps(3);
+ assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"));
+ h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z);
+ h.run_steps(3);
+ assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts the typing back");
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits");
+}
diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs
index d51f7f551f..b19f69cb46 100644
--- a/crates/ui-egui/tests/it/editing.rs
+++ b/crates/ui-egui/tests/it/editing.rs
@@ -6127,3 +6127,34 @@ fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() {
assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed");
assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back");
}
+
+/// Redo after Undo in the box puts the typing back, in the box: it doesn't reach the document,
+/// whose own Redo (a formatting change waiting here) must stay waiting.
+#[test]
+fn redo_after_undoing_typing_puts_the_typing_back() {
+ let mut h = harness(1, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ open_paragraph(&mut h);
+ h.get_by_label("Bold").click();
+ h.run_steps(4);
+ // Undo the bold (closing the editor), so the document has it to redo.
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(4);
+ assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text"));
+ open_paragraph(&mut h);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::End);
+ h.event(egui::Event::Text(" more".into()));
+ h.run_steps(2);
+ let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
+ h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
+ h.run_steps(3);
+ assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, typed, "the typing is taken back");
+ h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
+ h.run_steps(3);
+ let s = h.state();
+ let ed = s.views[0].line_editor.as_ref().expect("still typing");
+ assert_eq!(ed.text, typed, "Redo puts the typing back");
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits");
+}
From a44b850dac96fb4a375e9c54f693123fafed54f4 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:23:58 +1100
Subject: [PATCH 038/270] fix(ui): a narrow added-text box keeps its text at
its own edge
The editor is at least 60 points wide on screen, so a short box can be
clicked into. With the box's alignment applied, right-aligned or centred
text in a box narrower than that was placed against the wider editor's
edge, past the box's right edge. The editor now reaches past a narrow
box away from the edge its text keeps, so the text stays where the page
draws it.
---
crates/ui-egui/src/content_ui.rs | 17 +++++++++++++++--
crates/ui-egui/tests/it/content.rs | 23 +++++++++++++++++++++++
2 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index c9d2c33aba..58f954a895 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -559,6 +559,19 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
}
let r = screen_rect(&xf, info, page, draft.rect);
let source_rect = draft.index.map(|_| screen_rect(&xf, info, page, draft.style.rect));
+ // At least 60 points wide, so a short box can still be clicked into. Where that is wider than
+ // the box, the editor reaches past it away from the edge its text keeps (the right edge for
+ // right-aligned text, both halves for centred), so the text stays where the page draws it.
+ // shortcut: lines aligned differently from each other in a box under 60 points keep the
+ // editor's left edge; anchor each line on its own if such boxes turn out to matter.
+ let width = r.width().max(60.0);
+ let uniform = (0..pdfcraft_engine::paragraph_align::count(&draft.text)).all(|i| draft.style.paragraph_align(i) == draft.style.align);
+ let anchor = match draft.style.align {
+ pdfcraft_engine::TextAlign::Center if uniform => 0.5,
+ pdfcraft_engine::TextAlign::Right if uniform => 1.0,
+ _ => 0.0,
+ };
+ let at = r.min - egui::vec2((width - r.width()) * anchor, 0.0);
let background_ready = draft.index.is_some_and(|index| view.text_background.ready_added(page, index));
let tokens = Tokens::get(ctx);
let (mut commit, mut cancel, mut done) = (false, false, false);
@@ -575,7 +588,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
egui::Area::new(egui::Id::new(("added-text", view.id.0)))
.fade_in(false)
.order(egui::Order::Foreground)
- .fixed_pos(r.min)
+ .fixed_pos(at)
.constrain_to(view.viewport_rect())
.constrain(false)
.show(ctx, |ui| {
@@ -601,7 +614,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
ui.spacing_mut().item_spacing.x = 4.0;
let output = egui::TextEdit::multiline(&mut t.text)
.font(font)
- .desired_width(r.width().max(60.0))
+ .desired_width(width)
.desired_rows(1)
.layouter(&mut layouter)
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs
index 7edb13ef6a..a4024e513e 100644
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -737,6 +737,29 @@ fn the_text_editor_keeps_the_boxs_alignment() {
assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)");
}
+/// A box narrower than the editor's least width (60 points on screen): right-aligned text stays
+/// at the box's right edge, not the wider editor's, which lies past it.
+#[test]
+fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() {
+ let _gpu = gpu();
+ let mut h = harness();
+ let rect = [40.0, 195.0, 60.0, 210.0];
+ let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ let point = at(&h, 50.0, 195.0);
+ click(&mut h, point);
+ assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text");
+ h.run_steps(3);
+ let r = item_on_screen(&h, rect);
+ assert!(r.width() < 60.0, "the box is narrower than the editor: {}", r.width());
+ let pixels = h.render().unwrap();
+ let inside = ink(&pixels, r);
+ let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y)));
+ assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)");
+}
+
fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
}
From eba7e47159621e9993dd26b983ba9c560e2464e9 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:48:26 +1100
Subject: [PATCH 039/270] M7: text_edit with aligns alone keeps the paragraph's
rows
Without new text, text_edit rewrote a paragraph from its text joined
with spaces, so its rows were rewrapped into one and only the first
entry of `aligns`, which counts rows, had any effect. With `aligns`
given, the paragraph's own rows (its editor text) are kept, as in the
app.
---
crates/automation/src/lib.rs | 5 ++++-
crates/automation/tests/it/automation.rs | 20 ++++++++++++++++++++
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 8c5ae96bc3..c92ba9b8fb 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -696,7 +696,10 @@ impl Automation {
};
let index = self.paragraph_index(id, page, k, &blocks)?;
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
- let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
+ // `aligns` counts rows, so without new text the paragraph keeps its rows (the
+ // editor text); otherwise its text joined with spaces is rewrapped.
+ let own = if a.get("aligns").is_some() { block.editor_text } else { block.text };
+ let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(own);
let mut style = pdfcraft_engine::BlockStyle {
ranges: parse_style_ranges(a.get("ranges"))?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index 5b8931c6af..46f4bf391c 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -8447,3 +8447,23 @@ fn added_text_lines_take_their_own_alignments() {
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
}
+
+/// `aligns` alone on an existing paragraph aligns its rows by index, as the app does: the rows
+/// stay rows instead of being rewrapped into one, where only the first entry would count.
+#[test]
+fn text_edit_aligns_alone_aligns_the_paragraphs_rows() {
+ let dir = workdir("paragraph-row-aligns");
+ let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET";
+ std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] }));
+ let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
+ let rect = |t: &str| -> Vec {
+ let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == t).unwrap_or_else(|| panic!("{t:?} is a row: {lines}"));
+ line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
+ };
+ let (first, second) = (rect("The quick brown fox jumps over the lazy dog"), rect("and keeps running."));
+ assert!((first[0] - 72.0).abs() < 0.5, "the first row stays at the left: {first:?}");
+ assert!((second[2] - first[2]).abs() < 1.0 && second[0] > 100.0, "the second row is right-aligned: {second:?} vs {first:?}");
+}
From 7f0b451e2b4aaeb7bb31f9ec10670ac862a9d1f5 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:48:26 +1100
Subject: [PATCH 040/270] fix(ui): a narrow added-text box keeps the edge its
lines share
The editor of a box narrower than its 60-point minimum was anchored by
the box's own alignment. A box whose lines are all right-aligned (or
centred) on their own while the box stays Left wasn't anchored, so its
text was placed against the wider editor's edge, past the box. The
lines are now compared with each other and the editor keeps the edge
they share.
---
crates/ui-egui/src/content_ui.rs | 10 ++++++----
crates/ui-egui/tests/it/content.rs | 23 +++++++++++++++++++++++
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 58f954a895..5e4f2975aa 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -565,10 +565,12 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// shortcut: lines aligned differently from each other in a box under 60 points keep the
// editor's left edge; anchor each line on its own if such boxes turn out to matter.
let width = r.width().max(60.0);
- let uniform = (0..pdfcraft_engine::paragraph_align::count(&draft.text)).all(|i| draft.style.paragraph_align(i) == draft.style.align);
- let anchor = match draft.style.align {
- pdfcraft_engine::TextAlign::Center if uniform => 0.5,
- pdfcraft_engine::TextAlign::Right if uniform => 1.0,
+ // The alignment every line shares, its own or the box's.
+ let first = draft.style.paragraph_align(0);
+ let shared = (1..pdfcraft_engine::paragraph_align::count(&draft.text)).all(|i| draft.style.paragraph_align(i) == first).then_some(first);
+ let anchor = match shared {
+ Some(pdfcraft_engine::TextAlign::Center) => 0.5,
+ Some(pdfcraft_engine::TextAlign::Right) => 1.0,
_ => 0.0,
};
let at = r.min - egui::vec2((width - r.width()) * anchor, 0.0);
diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs
index a4024e513e..0798abdff5 100644
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -760,6 +760,29 @@ fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() {
assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)");
}
+/// The same when the line is right-aligned on its own while the box keeps Left: all its lines
+/// share one alignment, so the editor keeps the box's right edge for them.
+#[test]
+fn the_text_editor_aligns_a_narrow_box_whose_lines_share_an_alignment() {
+ let _gpu = gpu();
+ let mut h = harness();
+ let rect = [40.0, 195.0, 60.0, 210.0];
+ let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), aligns: vec![Some(pdfcraft_engine::TextAlign::Right)], ..Default::default() };
+ assert_eq!(text.align, pdfcraft_engine::TextAlign::Left);
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ let point = at(&h, 50.0, 195.0);
+ click(&mut h, point);
+ assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text");
+ h.run_steps(3);
+ let r = item_on_screen(&h, rect);
+ let pixels = h.render().unwrap();
+ let inside = ink(&pixels, r);
+ let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y)));
+ assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)");
+}
+
fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
}
From d162efacaa9722a3e214a8deeddeba00541231ac Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:57:46 +1100
Subject: [PATCH 041/270] test(ui): the Redo test checks Undo restores the
exact paragraph text
---
crates/ui-egui/tests/it/editing.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs
index b19f69cb46..2df634ef2f 100644
--- a/crates/ui-egui/tests/it/editing.rs
+++ b/crates/ui-egui/tests/it/editing.rs
@@ -6143,13 +6143,14 @@ fn redo_after_undoing_typing_puts_the_typing_back() {
h.run_steps(4);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text"));
open_paragraph(&mut h);
+ let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
- assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, typed, "the typing is taken back");
+ assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
let s = h.state();
From 489a76acbaf41319d9fbdc09f2038a404163bdd5 Mon Sep 17 00:00:00 2001
From: RiceDee <345114586+RiceDee@users.noreply.github.com>
Date: Fri, 23 Oct 2026 16:14:01 +1100
Subject: [PATCH 042/270] fix(ui): an unchanged added-text box closes without
an edit
Finishing a box trimmed its text before comparing it with the item, so
an unchanged box whose text was saved with trailing spaces finished as
an edit. Before an Undo or Redo step that edit was applied, clearing
the document's Redo. A box matching its item is now no edit.
---
crates/ui-egui/src/content_ui.rs | 8 ++++++++
crates/ui-egui/tests/it/content.rs | 26 ++++++++++++++++++++++++++
2 files changed, 34 insertions(+)
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 5e4f2975aa..ff987716d1 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -678,6 +678,14 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
/// A finished draft as an edit: new text is added (and selected once it exists), retyped text
/// updated, and text emptied by retyping removed. Empty new text adds nothing.
fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option